From 005f3e165f4cddf17b93c9825166cf07a6de8021 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 19:52:18 +0300 Subject: [PATCH 1/2] refactor(core): Extract shared modules and utilities --- packages/{javascript => core}/src/errors.ts | 0 packages/core/src/index.ts | 7 +++ .../src/modules/fetch-timer.ts} | 0 .../src/modules/sanitizer.ts | 6 ++- .../src/modules/stack-parser.ts} | 10 ++-- packages/core/src/transports/transport.ts | 9 ++++ .../{javascript => core}/src/utils/event.ts | 8 +-- .../src/utils/selector.ts | 0 .../src/utils/validation.ts | 41 ++++++-------- .../tests/utils/validation.test.ts | 2 +- packages/javascript/src/addons/breadcrumbs.ts | 5 +- .../javascript/src/addons/consoleCatcher.ts | 2 +- packages/javascript/src/catcher.ts | 45 +++++++++------- packages/javascript/src/modules/socket.ts | 3 +- .../javascript/src/types/catcher-message.ts | 19 +------ packages/javascript/src/types/event.ts | 53 +------------------ .../src/types/hawk-initial-settings.ts | 2 +- packages/javascript/src/types/transport.ts | 7 ++- 18 files changed, 83 insertions(+), 136 deletions(-) rename packages/{javascript => core}/src/errors.ts (100%) rename packages/{javascript/src/modules/fetchTimer.ts => core/src/modules/fetch-timer.ts} (100%) rename packages/{javascript => core}/src/modules/sanitizer.ts (98%) rename packages/{javascript/src/modules/stackParser.ts => core/src/modules/stack-parser.ts} (95%) create mode 100644 packages/core/src/transports/transport.ts rename packages/{javascript => core}/src/utils/event.ts (80%) rename packages/{javascript => core}/src/utils/selector.ts (100%) rename packages/{javascript => core}/src/utils/validation.ts (67%) rename packages/{javascript => core}/tests/utils/validation.test.ts (98%) 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..c9c0b32e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,10 @@ 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 { 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 100% rename from packages/javascript/src/modules/fetchTimer.ts rename to packages/core/src/modules/fetch-timer.ts diff --git a/packages/javascript/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts similarity index 98% rename from packages/javascript/src/modules/sanitizer.ts rename to packages/core/src/modules/sanitizer.ts index a02172d0..dadd3fff 100644 --- a/packages/javascript/src/modules/sanitizer.ts +++ b/packages/core/src/modules/sanitizer.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { isPlainObject } from '../utils/validation'; + /** * This class provides methods for preparing data to sending to Hawk * - trim long strings @@ -6,7 +8,7 @@ * - represent big objects as "" * - represent class as or */ -export default class Sanitizer { +export class Sanitizer { /** * Maximum string length */ @@ -34,7 +36,7 @@ export default class Sanitizer { * @param target - variable to check */ public static isObject(target: any): boolean { - return Sanitizer.typeOf(target) === 'object'; + return isPlainObject(target); } /** 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 ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); 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..c83a7c20 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,24 +1,33 @@ 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 +63,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 +513,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 +523,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 +582,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 +622,7 @@ export default class Catcher { * and reject() provided with text reason instead of Error() */ if (notAnError) { - return null; + return undefined; } try { @@ -621,7 +630,7 @@ export default class Catcher { } catch (e) { log('Can not parse stack:', 'warn', e); - return null; + return undefined; } } @@ -688,6 +697,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/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'> { } From 249ec7e0b0eac15366e1e4c457bd658d299de686 Mon Sep 17 00:00:00 2001 From: Reversean Date: Fri, 6 Mar 2026 16:38:00 +0300 Subject: [PATCH 2/2] refactor(core): Fix sanitizer --- packages/core/src/index.ts | 1 + packages/core/src/modules/fetch-timer.ts | 2 +- packages/core/src/modules/sanitizer.ts | 105 +++++++-------- packages/core/tests/modules/sanitizer.test.ts | 118 +++++++++++++++++ packages/core/tests/utils/validation.test.ts | 4 +- packages/javascript/src/catcher.ts | 1 + packages/javascript/src/modules/sanitizer.ts | 20 +++ .../tests/modules/sanitizer.test.ts | 122 ++---------------- 8 files changed, 208 insertions(+), 165 deletions(-) create mode 100644 packages/core/tests/modules/sanitizer.test.ts create mode 100644 packages/javascript/src/modules/sanitizer.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c9c0b32e..9529bf9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ 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'; diff --git a/packages/core/src/modules/fetch-timer.ts b/packages/core/src/modules/fetch-timer.ts index a17c1d47..1405bfd2 100644 --- a/packages/core/src/modules/fetch-timer.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 index dadd3fff..08472a13 100644 --- a/packages/core/src/modules/sanitizer.ts +++ b/packages/core/src/modules/sanitizer.ts @@ -1,10 +1,28 @@ /* 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 html elements like
as "
" instead of "{}" * - represent big objects as "" * - represent class as or */ @@ -30,6 +48,13 @@ export class Sanitizer { */ 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 * @@ -39,6 +64,17 @@ export class Sanitizer { 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 * @@ -62,19 +98,21 @@ export class Sanitizer { */ 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); + // 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 {...} -> - */ - } else if (Sanitizer.isClassPrototype(data)) { + /** + * If values is a not-constructed class, it will be formatted as "" + * class Editor {...} -> + */ + if (Sanitizer.isClassPrototype(data)) { return Sanitizer.formatClassPrototype(data); /** @@ -133,7 +171,9 @@ export class Sanitizer { * @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 | '' | '' { + private static sanitizeObject(data: { + [key: string]: any + }, depth: number, seen: WeakSet): Record | '' | '' { /** * If the maximum depth is reached, return a placeholder */ @@ -207,24 +247,6 @@ export class Sanitizer { 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 * @@ -250,31 +272,12 @@ export class Sanitizer { */ private static trimString(target: string): string { if (target.length > Sanitizer.maxStringLen) { - return target.substr(0, Sanitizer.maxStringLen) + '…'; + return target.substring(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 - */ - const innerHTML = target.innerHTML; - - if (innerHTML) { - return target.outerHTML.replace(target.innerHTML, '…'); - } - - return target.outerHTML; - } - /** * Represent not-constructed class as "" * diff --git a/packages/core/tests/modules/sanitizer.test.ts b/packages/core/tests/modules/sanitizer.test.ts new file mode 100644 index 00000000..d7055aeb --- /dev/null +++ b/packages/core/tests/modules/sanitizer.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { Sanitizer } from '../../src'; + +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 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/core/tests/utils/validation.test.ts b/packages/core/tests/utils/validation.test.ts index e8ac47c2..ab08ff25 100644 --- a/packages/core/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 '@hawk.so/core'; +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/catcher.ts b/packages/javascript/src/catcher.ts index c83a7c20..97356310 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,3 +1,4 @@ +import './modules/sanitizer'; import Socket from './modules/socket'; import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; diff --git a/packages/javascript/src/modules/sanitizer.ts b/packages/javascript/src/modules/sanitizer.ts new file mode 100644 index 00000000..071275a1 --- /dev/null +++ b/packages/javascript/src/modules/sanitizer.ts @@ -0,0 +1,20 @@ +import { Sanitizer } from '@hawk.so/core'; + +/** + * 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 -> "
" + */ +Sanitizer.registerHandler({ + check: (target) => target instanceof Element, + format: (target: Element) => { + const innerHTML = target.innerHTML; + + if (innerHTML) { + return target.outerHTML.replace(target.innerHTML, '…'); + } + + return target.outerHTML; + }, +}); 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'); }); }); });