diff --git a/packages/javascript/src/errors.ts b/packages/core/src/errors.ts similarity index 100% rename from packages/javascript/src/errors.ts rename to packages/core/src/errors.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4781ef8f..9529bf9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,11 @@ export type { HawkStorage } from './storages/hawk-storage'; export { HawkUserManager } from './users/hawk-user-manager'; export type { Logger, LogType } from './logger/logger'; export { isLoggerSet, setLogger, resetLogger, log } from './logger/logger'; +export { Sanitizer } from './modules/sanitizer'; +export type { SanitizerTypeHandler } from './modules/sanitizer'; +export { StackParser } from './modules/stack-parser'; +export { buildElementSelector } from './utils/selector'; +export type { Transport } from './transports/transport'; +export { EventRejectedError } from './errors'; +export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +export { isPlainObject, validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from './utils/validation'; diff --git a/packages/javascript/src/modules/fetchTimer.ts b/packages/core/src/modules/fetch-timer.ts similarity index 96% rename from packages/javascript/src/modules/fetchTimer.ts rename to packages/core/src/modules/fetch-timer.ts index a17c1d47..1405bfd2 100644 --- a/packages/javascript/src/modules/fetchTimer.ts +++ b/packages/core/src/modules/fetch-timer.ts @@ -1,4 +1,4 @@ -import { log } from '@hawk.so/core'; +import { log } from '../logger/logger'; /** * Sends AJAX request and wait for some time. diff --git a/packages/core/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts new file mode 100644 index 00000000..08472a13 --- /dev/null +++ b/packages/core/src/modules/sanitizer.ts @@ -0,0 +1,302 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { isPlainObject } from '../utils/validation'; + +/** + * Custom type handler for Sanitizer. + * + * Allows user to register their own formatters from external packages. + */ +export interface SanitizerTypeHandler { + /** + * Checks if this handler should be applied to given value + * + * @returns `true` + */ + check: (target: any) => boolean; + + /** + * Formats the value into a sanitized representation + */ + format: (target: any) => any; +} + +/** + * This class provides methods for preparing data to sending to Hawk + * - trim long strings + * - represent big objects as "" + * - represent class as or + */ +export class Sanitizer { + /** + * Maximum string length + */ + private static readonly maxStringLen: number = 200; + + /** + * If object in stringified JSON has more keys than this value, + * it will be represented as "" + */ + private static readonly maxObjectKeysCount: number = 20; + + /** + * Maximum depth of context object + */ + private static readonly maxDepth: number = 5; + + /** + * Maximum length of context arrays + */ + private static readonly maxArrayLength: number = 10; + + /** + * Custom type handlers registered via {@link registerHandler}. + * + * Checked in {@link sanitize} before built-in type checks. + */ + private static readonly customHandlers: SanitizerTypeHandler[] = []; + + /** + * Check if passed variable is an object + * + * @param target - variable to check + */ + public static isObject(target: any): boolean { + return isPlainObject(target); + } + + /** + * Register a custom type handler. + * Handlers are checked before built-in type checks, in reverse registration order + * (last registered = highest priority). + * + * @param handler - handler to register + */ + public static registerHandler(handler: SanitizerTypeHandler): void { + Sanitizer.customHandlers.unshift(handler); + } + + /** + * Apply sanitizing for array/object/primitives + * + * @param data - any object to sanitize + * @param depth - current depth of recursion + * @param seen - Set of already seen objects to prevent circular references + */ + public static sanitize(data: any, depth = 0, seen = new WeakSet()): any { + /** + * Check for circular references on objects and arrays + */ + if (data !== null && typeof data === 'object') { + if (seen.has(data)) { + return ''; + } + seen.add(data); + } + + /** + * If value is an Array, apply sanitizing for each element + */ + if (Sanitizer.isArray(data)) { + return this.sanitizeArray(data, depth + 1, seen); + } + + // Check additional handlers provided by env-specific modules or users + // to sanitize some additional cases (e.g. specific object types) + for (const handler of Sanitizer.customHandlers) { + if (handler.check(data)) { + return handler.format(data); + } + } + + /** + * If values is a not-constructed class, it will be formatted as "" + * class Editor {...} -> + */ + if (Sanitizer.isClassPrototype(data)) { + return Sanitizer.formatClassPrototype(data); + + /** + * If values is a some class instance, it will be formatted as "" + * new Editor() -> + */ + } else if (Sanitizer.isClassInstance(data)) { + return Sanitizer.formatClassInstance(data); + + /** + * If values is an object, do recursive call + */ + } else if (Sanitizer.isObject(data)) { + return Sanitizer.sanitizeObject(data, depth + 1, seen); + + /** + * If values is a string, trim it for max-length + */ + } else if (Sanitizer.isString(data)) { + return Sanitizer.trimString(data); + } + + /** + * If values is a number, boolean and other primitive, leave as is + */ + return data; + } + + /** + * Apply sanitizing for each element of the array + * + * @param arr - array to sanitize + * @param depth - current depth of recursion + * @param seen - Set of already seen objects to prevent circular references + */ + private static sanitizeArray(arr: any[], depth: number, seen: WeakSet): any[] { + /** + * If the maximum length is reached, slice array to max length and add a placeholder + */ + const length = arr.length; + + if (length > Sanitizer.maxArrayLength) { + arr = arr.slice(0, Sanitizer.maxArrayLength); + arr.push(`<${length - Sanitizer.maxArrayLength} more items...>`); + } + + return arr.map((item: any) => { + return Sanitizer.sanitize(item, depth, seen); + }); + } + + /** + * Process object values recursive + * + * @param data - object to beautify + * @param depth - current depth of recursion + * @param seen - Set of already seen objects to prevent circular references + */ + private static sanitizeObject(data: { + [key: string]: any + }, depth: number, seen: WeakSet): Record | '' | '' { + /** + * If the maximum depth is reached, return a placeholder + */ + if (depth > Sanitizer.maxDepth) { + return ''; + } + + /** + * If the object has more keys than the limit, return a placeholder + */ + if (Object.keys(data).length > Sanitizer.maxObjectKeysCount) { + return ''; + } + + const result: any = {}; + + for (const key in data) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + result[key] = Sanitizer.sanitize(data[key], depth, seen); + } + } + + return result; + } + + /** + * Check if passed variable is an array + * + * @param target - variable to check + */ + private static isArray(target: any): boolean { + return Array.isArray(target); + } + + /** + * Check if passed variable is a not-constructed class + * + * @param target - variable to check + */ + private static isClassPrototype(target: any): boolean { + if (!target || !target.constructor) { + return false; + } + + /** + * like + * "function Function { + * [native code] + * }" + */ + const constructorStr = target.constructor.toString(); + + return constructorStr.includes('[native code]') && constructorStr.includes('Function'); + } + + /** + * Check if passed variable is a constructed class instance + * + * @param target - variable to check + */ + private static isClassInstance(target: any): boolean { + return target && target.constructor && (/^class \S+ {/).test(target.constructor.toString()); + } + + /** + * Check if passed variable is a string + * + * @param target - variable to check + */ + private static isString(target: any): boolean { + return typeof target === 'string'; + } + + /** + * Return name of a passed class + * + * @param target - not-constructed class + */ + private static getClassNameByPrototype(target: any): string { + return target.name; + } + + /** + * Return name of a class by an instance + * + * @param target - instance of some class + */ + private static getClassNameByInstance(target: any): string { + return Sanitizer.getClassNameByPrototype(target.constructor); + } + + /** + * Trim string if it reaches max length + * + * @param target - string to check + */ + private static trimString(target: string): string { + if (target.length > Sanitizer.maxStringLen) { + return target.substring(0, Sanitizer.maxStringLen) + '…'; + } + + return target; + } + + /** + * Represent not-constructed class as "" + * + * @param target - class to format + */ + private static formatClassPrototype(target: any): string { + const className = Sanitizer.getClassNameByPrototype(target); + + return ``; + } + + /** + * Represent a some class instance as a "" + * + * @param target - class instance to format + */ + private static formatClassInstance(target: any): string { + const className = Sanitizer.getClassNameByInstance(target); + + return ``; + } +} diff --git a/packages/javascript/src/modules/stackParser.ts b/packages/core/src/modules/stack-parser.ts similarity index 95% rename from packages/javascript/src/modules/stackParser.ts rename to packages/core/src/modules/stack-parser.ts index f7ee32f8..8ea7518c 100644 --- a/packages/javascript/src/modules/stackParser.ts +++ b/packages/core/src/modules/stack-parser.ts @@ -1,12 +1,12 @@ import type { StackFrame } from 'error-stack-parser'; import ErrorStackParser from 'error-stack-parser'; import type { BacktraceFrame, SourceCodeLine } from '@hawk.so/types'; -import fetchTimer from './fetchTimer'; +import fetchTimer from './fetch-timer'; /** * This module prepares parsed backtrace */ -export default class StackParser { +export class StackParser { /** * Prevents loading one file several times * name -> content @@ -48,7 +48,7 @@ export default class StackParser { try { if (!frame.fileName) { return null; - }; + } if (!this.isValidUrl(frame.fileName)) { return null; @@ -118,9 +118,9 @@ export default class StackParser { /** * Downloads source file * - * @param {string} fileName - name of file to download + * @param fileName - name of file to download */ - private async loadSourceFile(fileName): Promise { + private async loadSourceFile(fileName: string): Promise { if (this.sourceFilesCache[fileName] !== undefined) { return this.sourceFilesCache[fileName]; } diff --git a/packages/core/src/transports/transport.ts b/packages/core/src/transports/transport.ts new file mode 100644 index 00000000..5ed26727 --- /dev/null +++ b/packages/core/src/transports/transport.ts @@ -0,0 +1,9 @@ +import type { CatcherMessage } from '@hawk.so/types'; +import { CatcherMessageType } from '@hawk.so/types'; + +/** + * Transport interface — anything that can send a CatcherMessage + */ +export interface Transport { + send(message: CatcherMessage): Promise; +} diff --git a/packages/javascript/src/utils/event.ts b/packages/core/src/utils/event.ts similarity index 80% rename from packages/javascript/src/utils/event.ts rename to packages/core/src/utils/event.ts index 63741533..ad381418 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/core/src/utils/event.ts @@ -1,4 +1,4 @@ -import { log } from '@hawk.so/core'; +import { log } from '../logger/logger'; /** * Symbol to mark error as processed by Hawk @@ -6,7 +6,7 @@ import { log } from '@hawk.so/core'; const errorSentShadowProperty = Symbol('__hawk_processed__'); /** - * Check if the error has alrady been sent to Hawk. + * Check if the error has already been sent to Hawk. * * Motivation: * Some integrations may catch errors on their own side and then normally re-throw them down. @@ -20,7 +20,7 @@ export function isErrorProcessed(error: unknown): boolean { return false; } - return error[errorSentShadowProperty] === true; + return (error as Record)[errorSentShadowProperty] === true; } /** @@ -35,7 +35,7 @@ export function markErrorAsProcessed(error: unknown): void { } Object.defineProperty(error, errorSentShadowProperty, { - enumerable: false, // Prevent from beight collected by Hawk + enumerable: false, // Prevent from being collected by Hawk value: true, writable: true, configurable: true, diff --git a/packages/javascript/src/utils/selector.ts b/packages/core/src/utils/selector.ts similarity index 100% rename from packages/javascript/src/utils/selector.ts rename to packages/core/src/utils/selector.ts diff --git a/packages/javascript/src/utils/validation.ts b/packages/core/src/utils/validation.ts similarity index 67% rename from packages/javascript/src/utils/validation.ts rename to packages/core/src/utils/validation.ts index 293cafc6..7fd45185 100644 --- a/packages/javascript/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,6 +1,12 @@ -import { log } from '@hawk.so/core'; -import type { AffectedUser, Breadcrumb, EventContext, EventData, JavaScriptAddons } from '@hawk.so/types'; -import Sanitizer from '../modules/sanitizer'; +import { log } from '../logger/logger'; +import type { AffectedUser, Breadcrumb, EventAddons, EventContext, EventData } from '@hawk.so/types'; + +/** + * Returns true if value is a plain object (not null, array, Date, Map, etc.) + */ +export function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} /** * Validates user data - basic security checks @@ -8,7 +14,7 @@ import Sanitizer from '../modules/sanitizer'; * @param user - user data to validate */ export function validateUser(user: AffectedUser): boolean { - if (!user || !Sanitizer.isObject(user)) { + if (!user || !isPlainObject(user)) { log('validateUser: User must be an object', 'warn'); return false; @@ -30,7 +36,7 @@ export function validateUser(user: AffectedUser): boolean { * @param context - context data to validate */ export function validateContext(context: EventContext | undefined): boolean { - if (context && !Sanitizer.isObject(context)) { + if (context && !isPlainObject(context)) { log('validateContext: Context must be an object', 'warn'); return false; @@ -39,23 +45,14 @@ export function validateContext(context: EventContext | undefined): boolean { return true; } -/** - * Checks if value is a plain object (not array, Date, etc.) - * - * @param value - value to check - */ -function isPlainObject(value: unknown): value is Record { - return Object.prototype.toString.call(value) === '[object Object]'; -} - /** * Runtime check for required EventData fields. * Per @hawk.so/types EventData, `title` is the only non-optional field. - * Additionally validates `backtrace` shape if present (must be an array). + * Additionally, validates `backtrace` shape if present (must be an array). * * @param payload - value to validate */ -export function isValidEventPayload(payload: unknown): payload is EventData { +export function isValidEventPayload(payload: unknown): payload is EventData { if (!isPlainObject(payload)) { return false; } @@ -64,11 +61,7 @@ export function isValidEventPayload(payload: unknown): payload is EventData { + describe('isObject', () => { + it('should return true for a plain object', () => { + expect(Sanitizer.isObject({})).toBe(true); + }); + + it('should return false for an array', () => { + expect(Sanitizer.isObject([])).toBe(false); + }); + + it('should return false for a string', () => { + expect(Sanitizer.isObject('x')).toBe(false); + }); + + it('should return false for a boolean', () => { + expect(Sanitizer.isObject(true)).toBe(false); + }); + + it('should return false for null', () => { + expect(Sanitizer.isObject(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(Sanitizer.isObject(undefined)).toBe(false); + }); + }); + + describe('sanitize', () => { + it('should pass through strings within the length limit', () => { + expect(Sanitizer.sanitize('hello')).toBe('hello'); + }); + + it('should trim strings longer than maxStringLen', () => { + const long = 'a'.repeat(201); + const result = Sanitizer.sanitize(long); + + expect(result).toBe('a'.repeat(200) + '…'); + }); + + it('should pass through short arrays unchanged', () => { + expect(Sanitizer.sanitize([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('should truncate arrays over maxArrayLength items and append placeholder', () => { + const arr = Array.from({ length: 12 }, (_, i) => i); + const result = Sanitizer.sanitize(arr); + + expect(result).toHaveLength(11); + expect(result[10]).toBe('<2 more items...>'); + }); + + it('should sanitize nested objects recursively', () => { + const longStr = 'a'.repeat(201); + const longArr = Array.from({ length: 12 }, (_, i) => i); + const obj = { + foo: 'x', + bar: longStr, + baz: longArr + } + const result = Sanitizer.sanitize(obj); + + expect(result.foo).toBe('x'); + expect(result.bar).toBe('a'.repeat(200) + '…'); + expect(result.baz).toHaveLength(11); + expect(result.baz[10]).toBe('<2 more items...>'); + }); + + it('should replace objects with more than 20 keys with placeholder', () => { + const big: Record = {}; + + for (let i = 0; i < 21; i++) { + big[`k${i}`] = i; + } + + expect(Sanitizer.sanitize(big)).toBe(''); + }); + + it('should replace deeply nested objects with placeholder', () => { + const deep = { a: { b: { c: { d: { e: { f: 'bottom' } } } } } }; + const result = Sanitizer.sanitize(deep); + + expect(result.a.b.c.d.e).toBe(''); + }); + + it('should format a class (not constructed) as ""', () => { + class Foo {} + + expect(Sanitizer.sanitize(Foo)).toBe(''); + }); + + it('should format a class instance as ""', () => { + class Foo {} + + expect(Sanitizer.sanitize(new Foo())).toBe(''); + }); + + it('should replace circular references with placeholder', () => { + const obj: any = { a: 1 }; + + obj.self = obj; + + const result = Sanitizer.sanitize(obj); + + expect(result.self).toBe(''); + }); + + it.each([ + { label: 'number', value: 42 }, + { label: 'boolean', value: true }, + { label: 'null', value: null }, + ])('should pass through $label primitives unchanged', ({ value }) => { + expect(Sanitizer.sanitize(value)).toBe(value); + }); + }); +}); diff --git a/packages/javascript/tests/utils/validation.test.ts b/packages/core/tests/utils/validation.test.ts similarity index 95% rename from packages/javascript/tests/utils/validation.test.ts rename to packages/core/tests/utils/validation.test.ts index 20d02944..ab08ff25 100644 --- a/packages/javascript/tests/utils/validation.test.ts +++ b/packages/core/tests/utils/validation.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; -import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '../../src/utils/validation'; +import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '../../src'; // Suppress log output produced by log() calls inside validation failures. -vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); +vi.mock('../../src/logger/logger', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); describe('validateUser', () => { it('should return false when user is null', () => { diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index 1e4f0b9b..972ba205 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -2,10 +2,7 @@ * @file Breadcrumbs module - captures chronological trail of events before an error */ import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json, JsonNode } from '@hawk.so/types'; -import Sanitizer from '../modules/sanitizer'; -import { buildElementSelector } from '../utils/selector'; -import { log } from '@hawk.so/core'; -import { isValidBreadcrumb } from '../utils/validation'; +import { Sanitizer, buildElementSelector, log, isValidBreadcrumb } from '@hawk.so/core'; /** * Default maximum number of breadcrumbs to store diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 29519eaa..f5742b7e 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -2,7 +2,7 @@ * @file Module for intercepting console logs with stack trace capture */ import type { ConsoleLogEvent } from '@hawk.so/types'; -import Sanitizer from '../modules/sanitizer'; +import { Sanitizer } from '@hawk.so/core'; /** * Maximum number of console logs to store diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 7bc1b28a..97356310 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,24 +1,34 @@ +import './modules/sanitizer'; import Socket from './modules/socket'; -import Sanitizer from './modules/sanitizer'; -import StackParser from './modules/stackParser'; -import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; +import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; import { id } from './utils/id'; import type { AffectedUser, + DecodedIntegrationToken, + EncodedIntegrationToken, EventContext, JavaScriptAddons, - VueIntegrationAddons, - Json, EncodedIntegrationToken, DecodedIntegrationToken + Json, + VueIntegrationAddons } from '@hawk.so/types'; -import type { JavaScriptCatcherIntegrations } from './types/integrations'; -import { EventRejectedError } from './errors'; -import type { HawkJavaScriptEvent } from './types'; -import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; -import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; -import { HawkUserManager, setLogger, isLoggerSet, log } from '@hawk.so/core'; +import { + EventRejectedError, + HawkUserManager, + isErrorProcessed, + isLoggerSet, + isValidEventPayload, + log, + markErrorAsProcessed, + Sanitizer, + setLogger, + StackParser, + validateContext, + validateUser +} from '@hawk.so/core'; import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; @@ -54,7 +64,7 @@ export default class Catcher { /** * Catcher Type */ - private readonly type: string = 'errors/javascript'; + private readonly type = 'errors/javascript' as const; /** * User project's Integration Token @@ -504,7 +514,7 @@ export default class Catcher { * and reject() provided with text reason instead of Error() */ if (notAnError) { - return null; + return undefined; } return (error as Error).name; @@ -514,7 +524,7 @@ export default class Catcher { * Release version */ private getRelease(): HawkJavaScriptEvent['release'] { - return this.release !== undefined ? String(this.release) : null; + return this.release !== undefined ? String(this.release) : undefined; } /** @@ -573,7 +583,7 @@ export default class Catcher { private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] { const breadcrumbs = this.breadcrumbManager?.getBreadcrumbs(); - return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : null; + return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : undefined; } /** @@ -613,7 +623,7 @@ export default class Catcher { * and reject() provided with text reason instead of Error() */ if (notAnError) { - return null; + return undefined; } try { @@ -621,7 +631,7 @@ export default class Catcher { } catch (e) { log('Can not parse stack:', 'warn', e); - return null; + return undefined; } } @@ -688,6 +698,6 @@ export default class Catcher { * @param integrationAddons - extra addons */ private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void { - Object.assign(errorFormatted.payload.addons, integrationAddons); + Object.assign(errorFormatted.payload.addons!, integrationAddons); } } diff --git a/packages/javascript/src/modules/sanitizer.ts b/packages/javascript/src/modules/sanitizer.ts index a02172d0..071275a1 100644 --- a/packages/javascript/src/modules/sanitizer.ts +++ b/packages/javascript/src/modules/sanitizer.ts @@ -1,269 +1,14 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Sanitizer } from '@hawk.so/core'; + /** - * This class provides methods for preparing data to sending to Hawk - * - trim long strings - * - represent html elements like
as "
" instead of "{}" - * - represent big objects as "" - * - represent class as or + * Registers browser-specific sanitizer handler for {@link Element} objects. + * + * Handles HTML Element and represents as string with it outer HTML with + * inner content replaced: HTMLDivElement -> "
" */ -export default class Sanitizer { - /** - * Maximum string length - */ - private static readonly maxStringLen: number = 200; - - /** - * If object in stringified JSON has more keys than this value, - * it will be represented as "" - */ - private static readonly maxObjectKeysCount: number = 20; - - /** - * Maximum depth of context object - */ - private static readonly maxDepth: number = 5; - - /** - * Maximum length of context arrays - */ - private static readonly maxArrayLength: number = 10; - - /** - * Check if passed variable is an object - * - * @param target - variable to check - */ - public static isObject(target: any): boolean { - return Sanitizer.typeOf(target) === 'object'; - } - - /** - * Apply sanitizing for array/object/primitives - * - * @param data - any object to sanitize - * @param depth - current depth of recursion - * @param seen - Set of already seen objects to prevent circular references - */ - public static sanitize(data: any, depth = 0, seen = new WeakSet()): any { - /** - * Check for circular references on objects and arrays - */ - if (data !== null && typeof data === 'object') { - if (seen.has(data)) { - return ''; - } - seen.add(data); - } - - /** - * If value is an Array, apply sanitizing for each element - */ - if (Sanitizer.isArray(data)) { - return this.sanitizeArray(data, depth + 1, seen); - - /** - * If value is an Element, format it as string with outer HTML - * HTMLDivElement -> "
" - */ - } else if (Sanitizer.isElement(data)) { - return Sanitizer.formatElement(data); - - /** - * If values is a not-constructed class, it will be formatted as "" - * class Editor {...} -> - */ - } else if (Sanitizer.isClassPrototype(data)) { - return Sanitizer.formatClassPrototype(data); - - /** - * If values is a some class instance, it will be formatted as "" - * new Editor() -> - */ - } else if (Sanitizer.isClassInstance(data)) { - return Sanitizer.formatClassInstance(data); - - /** - * If values is an object, do recursive call - */ - } else if (Sanitizer.isObject(data)) { - return Sanitizer.sanitizeObject(data, depth + 1, seen); - - /** - * If values is a string, trim it for max-length - */ - } else if (Sanitizer.isString(data)) { - return Sanitizer.trimString(data); - } - - /** - * If values is a number, boolean and other primitive, leave as is - */ - return data; - } - - /** - * Apply sanitizing for each element of the array - * - * @param arr - array to sanitize - * @param depth - current depth of recursion - * @param seen - Set of already seen objects to prevent circular references - */ - private static sanitizeArray(arr: any[], depth: number, seen: WeakSet): any[] { - /** - * If the maximum length is reached, slice array to max length and add a placeholder - */ - const length = arr.length; - - if (length > Sanitizer.maxArrayLength) { - arr = arr.slice(0, Sanitizer.maxArrayLength); - arr.push(`<${length - Sanitizer.maxArrayLength} more items...>`); - } - - return arr.map((item: any) => { - return Sanitizer.sanitize(item, depth, seen); - }); - } - - /** - * Process object values recursive - * - * @param data - object to beautify - * @param depth - current depth of recursion - * @param seen - Set of already seen objects to prevent circular references - */ - private static sanitizeObject(data: { [key: string]: any }, depth: number, seen: WeakSet): Record | '' | '' { - /** - * If the maximum depth is reached, return a placeholder - */ - if (depth > Sanitizer.maxDepth) { - return ''; - } - - /** - * If the object has more keys than the limit, return a placeholder - */ - if (Object.keys(data).length > Sanitizer.maxObjectKeysCount) { - return ''; - } - - const result: any = {}; - - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - result[key] = Sanitizer.sanitize(data[key], depth, seen); - } - } - - return result; - } - - /** - * Check if passed variable is an array - * - * @param target - variable to check - */ - private static isArray(target: any): boolean { - return Array.isArray(target); - } - - /** - * Check if passed variable is a not-constructed class - * - * @param target - variable to check - */ - private static isClassPrototype(target: any): boolean { - if (!target || !target.constructor) { - return false; - } - - /** - * like - * "function Function { - * [native code] - * }" - */ - const constructorStr = target.constructor.toString(); - - return constructorStr.includes('[native code]') && constructorStr.includes('Function'); - } - - /** - * Check if passed variable is a constructed class instance - * - * @param target - variable to check - */ - private static isClassInstance(target: any): boolean { - return target && target.constructor && (/^class \S+ {/).test(target.constructor.toString()); - } - - /** - * Check if passed variable is a string - * - * @param target - variable to check - */ - private static isString(target: any): boolean { - return typeof target === 'string'; - } - - /** - * Return string representation of the object type - * - * @param object - object to get type - */ - private static typeOf(object: any): string { - return Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/)[1].toLowerCase(); - } - - /** - * Check if passed variable is an HTML Element - * - * @param target - variable to check - */ - private static isElement(target: any): boolean { - return target instanceof Element; - } - - /** - * Return name of a passed class - * - * @param target - not-constructed class - */ - private static getClassNameByPrototype(target: any): string { - return target.name; - } - - /** - * Return name of a class by an instance - * - * @param target - instance of some class - */ - private static getClassNameByInstance(target: any): string { - return Sanitizer.getClassNameByPrototype(target.constructor); - } - - /** - * Trim string if it reaches max length - * - * @param target - string to check - */ - private static trimString(target: string): string { - if (target.length > Sanitizer.maxStringLen) { - return target.substr(0, Sanitizer.maxStringLen) + '…'; - } - - return target; - } - - /** - * Represent HTML Element as string with it outer-html - * HTMLDivElement -> "
" - * - * @param target - variable to format - */ - private static formatElement(target: Element): string { - /** - * Also, remove inner HTML because it can be BIG - */ +Sanitizer.registerHandler({ + check: (target) => target instanceof Element, + format: (target: Element) => { const innerHTML = target.innerHTML; if (innerHTML) { @@ -271,27 +16,5 @@ export default class Sanitizer { } return target.outerHTML; - } - - /** - * Represent not-constructed class as "" - * - * @param target - class to format - */ - private static formatClassPrototype(target: any): string { - const className = Sanitizer.getClassNameByPrototype(target); - - return ``; - } - - /** - * Represent a some class instance as a "" - * - * @param target - class instance to format - */ - private static formatClassInstance(target: any): string { - const className = Sanitizer.getClassNameByInstance(target); - - return ``; - } -} + }, +}); diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 930a9e53..24add33d 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -1,6 +1,5 @@ import { log } from '@hawk.so/core'; -import type { CatcherMessage } from '@/types'; -import type { Transport } from '../types/transport'; +import type { CatcherMessage, Transport } from '@/types'; /** * Custom WebSocket wrapper class diff --git a/packages/javascript/src/types/catcher-message.ts b/packages/javascript/src/types/catcher-message.ts index d892e22a..84507534 100644 --- a/packages/javascript/src/types/catcher-message.ts +++ b/packages/javascript/src/types/catcher-message.ts @@ -1,21 +1,6 @@ -import type { HawkJavaScriptEvent } from './event'; +import type { CatcherMessage as HawkCatcherMessage } from '@hawk.so/types'; /** * Structure describing a message sending by Catcher */ -export interface CatcherMessage { - /** - * User project's Integration Token - */ - token: string; - - /** - * Hawk Catcher name - */ - catcherType: string; - - /** - * All information about the event - */ - payload: HawkJavaScriptEvent; -} +export type CatcherMessage = HawkCatcherMessage<'errors/javascript'>; diff --git a/packages/javascript/src/types/event.ts b/packages/javascript/src/types/event.ts index 82dec497..89eee08c 100644 --- a/packages/javascript/src/types/event.ts +++ b/packages/javascript/src/types/event.ts @@ -1,55 +1,6 @@ -import type { AffectedUser, BacktraceFrame, EventContext, EventData, JavaScriptAddons, Breadcrumb } from '@hawk.so/types'; - -/** - * Event data with JS specific addons - */ -type JSEventData = EventData; +import type { CatcherMessagePayload } from '@hawk.so/types'; /** * Event will be sent to Hawk by Hawk JavaScript SDK - * - * The listed EventData properties will always be sent, so we define them as required in the type */ -export type HawkJavaScriptEvent = Omit & { - /** - * Event type: TypeError, ReferenceError etc - * null for non-error events - */ - type: string | null; - - /** - * Current release (aka version, revision) of an application - */ - release: string | null; - - /** - * Breadcrumbs - chronological trail of events before the error - */ - breadcrumbs: Breadcrumb[] | null; - - /** - * Current authenticated user - */ - user: AffectedUser | null; - - /** - * Any other information collected and passed by user - */ - context: EventContext; - - /** - * Catcher-specific information - */ - addons: JavaScriptAddons; - - /** - * Stack - * From the latest call to the earliest - */ - backtrace: BacktraceFrame[] | null; - - /** - * Catcher version - */ - catcherVersion: string; -}; +export type HawkJavaScriptEvent = CatcherMessagePayload<'errors/javascript'>; diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 987cdf4c..96b7fc75 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -1,6 +1,6 @@ import type { EventContext, AffectedUser } from '@hawk.so/types'; import type { HawkJavaScriptEvent } from './event'; -import type { Transport } from './transport'; +import type { Transport } from '@/types'; import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; /** diff --git a/packages/javascript/src/types/transport.ts b/packages/javascript/src/types/transport.ts index f2237dca..c8df0709 100644 --- a/packages/javascript/src/types/transport.ts +++ b/packages/javascript/src/types/transport.ts @@ -1,8 +1,7 @@ -import type { CatcherMessage } from './catcher-message'; +import type { Transport as HawkTransport } from '@hawk.so/core'; /** - * Transport interface — anything that can send a CatcherMessage + * Transport interface — anything that can send a {@link CatcherMessage}. */ -export interface Transport { - send(message: CatcherMessage): Promise; +export interface Transport extends HawkTransport<'errors/javascript'> { } diff --git a/packages/javascript/tests/modules/sanitizer.test.ts b/packages/javascript/tests/modules/sanitizer.test.ts index 7b79c6be..b563f787 100644 --- a/packages/javascript/tests/modules/sanitizer.test.ts +++ b/packages/javascript/tests/modules/sanitizer.test.ts @@ -1,91 +1,10 @@ import { describe, it, expect } from 'vitest'; -import Sanitizer from '../../src/modules/sanitizer'; +import { Sanitizer } from '@hawk.so/core'; +import '../../src/modules/sanitizer'; -describe('Sanitizer', () => { - describe('isObject', () => { - it('should return true for a plain object', () => { - expect(Sanitizer.isObject({})).toBe(true); - }); - - it('should return false for an array', () => { - expect(Sanitizer.isObject([])).toBe(false); - }); - - it('should return false for a string', () => { - expect(Sanitizer.isObject('x')).toBe(false); - }); - - it('should return false for a boolean', () => { - expect(Sanitizer.isObject(true)).toBe(false); - }); - - it('should return false for null', () => { - expect(Sanitizer.isObject(null)).toBe(false); - }); - - it('should return false for undefined', () => { - expect(Sanitizer.isObject(undefined)).toBe(false); - }); - }); - - describe('sanitize', () => { - it('should pass through strings within the length limit', () => { - expect(Sanitizer.sanitize('hello')).toBe('hello'); - }); - - it('should trim strings longer than maxStringLen', () => { - const long = 'a'.repeat(201); - const result = Sanitizer.sanitize(long); - - expect(result).toBe('a'.repeat(200) + '…'); - }); - - it('should pass through short arrays unchanged', () => { - expect(Sanitizer.sanitize([1, 2, 3])).toEqual([1, 2, 3]); - }); - - it('should truncate arrays over maxArrayLength items and append placeholder', () => { - const arr = Array.from({ length: 12 }, (_, i) => i); - const result = Sanitizer.sanitize(arr); - - expect(result).toHaveLength(11); - expect(result[10]).toBe('<2 more items...>'); - }); - - it('should sanitize nested objects recursively', () => { - const longStr = 'a'.repeat(201); - const longArr = Array.from({ length: 12 }, (_, i) => i); - const obj = { - foo: 'x', - bar: longStr, - baz: longArr - } - const result = Sanitizer.sanitize(obj); - - expect(result.foo).toBe('x'); - expect(result.bar).toBe('a'.repeat(200) + '…'); - expect(result.baz).toHaveLength(11); - expect(result.baz[10]).toBe('<2 more items...>'); - }); - - it('should replace objects with more than 20 keys with placeholder', () => { - const big: Record = {}; - - for (let i = 0; i < 21; i++) { - big[`k${i}`] = i; - } - - expect(Sanitizer.sanitize(big)).toBe(''); - }); - - it('should replace deeply nested objects with placeholder', () => { - const deep = { a: { b: { c: { d: { e: { f: 'bottom' } } } } } }; - const result = Sanitizer.sanitize(deep); - - expect(result.a.b.c.d.e).toBe(''); - }); - - it('should format HTML elements as a string starting with tag', () => { +describe('Browser Sanitizer handlers', () => { + describe('Element handler', () => { + it('should format an empty HTML element as its outer HTML', () => { const el = document.createElement('div'); const result = Sanitizer.sanitize(el); @@ -93,34 +12,15 @@ describe('Sanitizer', () => { expect(result).toMatch(/^
"', () => { - class Foo {} - - expect(Sanitizer.sanitize(Foo)).toBe(''); - }); - - it('should format a class instance as ""', () => { - class Foo {} - - expect(Sanitizer.sanitize(new Foo())).toBe(''); - }); - - it('should replace circular references with placeholder', () => { - const obj: any = { a: 1 }; - - obj.self = obj; + it('should replace inner HTML content with ellipsis', () => { + const el = document.createElement('div'); - const result = Sanitizer.sanitize(obj); + el.innerHTML = 'some long content'; - expect(result.self).toBe(''); - }); + const result = Sanitizer.sanitize(el); - it.each([ - { label: 'number', value: 42 }, - { label: 'boolean', value: true }, - { label: 'null', value: null }, - ])('should pass through $label primitives unchanged', ({ value }) => { - expect(Sanitizer.sanitize(value)).toBe(value); + expect(result).toContain('…'); + expect(result).not.toContain('some long content'); }); }); });