Skip to content

Commit 6e0faf5

Browse files
committed
refactor(core): Extract sanitizer module
- Sanitizer moved to @hawk.so/core (except Element handling) - SanitizerTypeHandler feature added - browser Element SanitizerTypeHandler added - type-guards utils moved from sanitizer - type-guards utils covered with tests
1 parent 835a79f commit 6e0faf5

File tree

10 files changed

+231
-181
lines changed

10 files changed

+231
-181
lines changed

packages/core/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { HawkUserManager } from './users/hawk-user-manager';
44
export type { Logger, LogType } from './logger/logger';
55
export { isLoggerSet, setLogger, resetLogger, log } from './logger/logger';
66
export { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from './utils/validation';
7-
export { isPlainObject } from './utils/type-guards';
7+
export { isPlainObject, isArray, isClassPrototype, isClassInstance, isString } from './utils/type-guards';
8+
export { Sanitizer } from './modules/sanitizer';

packages/javascript/src/modules/sanitizer.ts renamed to packages/core/src/modules/sanitizer.ts

Lines changed: 63 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { isArray, isClassInstance, isClassPrototype, isPlainObject, isString } from '../utils/type-guards';
3+
4+
/**
5+
* Custom type handler for Sanitizer.
6+
*
7+
* Allows user to register their own formatters from external packages.
8+
*/
9+
export interface SanitizerTypeHandler {
10+
/**
11+
* Checks if this handler should be applied to given value
12+
*
13+
* @returns `true`
14+
*/
15+
check: (target: any) => boolean;
16+
17+
/**
18+
* Formats the value into a sanitized representation
19+
*/
20+
format: (target: any) => any;
21+
}
22+
223
/**
324
* This class provides methods for preparing data to sending to Hawk
425
* - trim long strings
5-
* - represent html elements like <div ...> as "<div>" instead of "{}"
626
* - represent big objects as "<big object>"
727
* - represent class as <class SomeClass> or <instance of SomeClass>
828
*/
9-
export default class Sanitizer {
29+
export class Sanitizer {
1030
/**
1131
* Maximum string length
1232
*/
@@ -29,12 +49,21 @@ export default class Sanitizer {
2949
private static readonly maxArrayLength: number = 10;
3050

3151
/**
32-
* Check if passed variable is an object
52+
* Custom type handlers registered via {@link registerHandler}.
3353
*
34-
* @param target - variable to check
54+
* Checked in {@link sanitize} before built-in type checks.
3555
*/
36-
public static isObject(target: any): boolean {
37-
return Sanitizer.typeOf(target) === 'object';
56+
private static readonly customHandlers: SanitizerTypeHandler[] = [];
57+
58+
/**
59+
* Register a custom type handler.
60+
* Handlers are checked before built-in type checks, in reverse registration order
61+
* (last registered = highest priority).
62+
*
63+
* @param handler - handler to register
64+
*/
65+
public static registerHandler(handler: SanitizerTypeHandler): void {
66+
Sanitizer.customHandlers.unshift(handler);
3867
}
3968

4069
/**
@@ -45,59 +74,39 @@ export default class Sanitizer {
4574
* @param seen - Set of already seen objects to prevent circular references
4675
*/
4776
public static sanitize(data: any, depth = 0, seen = new WeakSet<object>()): any {
48-
/**
49-
* Check for circular references on objects and arrays
50-
*/
77+
// Check for circular references on objects and arrays
5178
if (data !== null && typeof data === 'object') {
5279
if (seen.has(data)) {
5380
return '<circular>';
5481
}
5582
seen.add(data);
5683
}
5784

58-
/**
59-
* If value is an Array, apply sanitizing for each element
60-
*/
61-
if (Sanitizer.isArray(data)) {
62-
return this.sanitizeArray(data, depth + 1, seen);
63-
64-
/**
65-
* If value is an Element, format it as string with outer HTML
66-
* HTMLDivElement -> "<div ...></div>"
67-
*/
68-
} else if (Sanitizer.isElement(data)) {
69-
return Sanitizer.formatElement(data);
70-
71-
/**
72-
* If values is a not-constructed class, it will be formatted as "<class SomeClass>"
73-
* class Editor {...} -> <class Editor>
74-
*/
75-
} else if (Sanitizer.isClassPrototype(data)) {
76-
return Sanitizer.formatClassPrototype(data);
77-
78-
/**
79-
* If values is a some class instance, it will be formatted as "<instance of SomeClass>"
80-
* new Editor() -> <instance of Editor>
81-
*/
82-
} else if (Sanitizer.isClassInstance(data)) {
83-
return Sanitizer.formatClassInstance(data);
84-
85-
/**
86-
* If values is an object, do recursive call
87-
*/
88-
} else if (Sanitizer.isObject(data)) {
89-
return Sanitizer.sanitizeObject(data, depth + 1, seen);
90-
91-
/**
92-
* If values is a string, trim it for max-length
93-
*/
94-
} else if (Sanitizer.isString(data)) {
95-
return Sanitizer.trimString(data);
85+
86+
// If value is an Array, apply sanitizing for each element
87+
if (isArray(data)) return this.sanitizeArray(data, depth + 1, seen);
88+
89+
// Check additional handlers provided by env-specific modules or users
90+
// to sanitize some additional cases (e.g. specific object types)
91+
for (const handler of Sanitizer.customHandlers) {
92+
if (handler.check(data)) return handler.format(data);
9693
}
9794

98-
/**
99-
* If values is a number, boolean and other primitive, leave as is
100-
*/
95+
// If values is a not-constructed class, it will be formatted as "<class SomeClass>"
96+
// class Editor {...} -> <class Editor>
97+
if (isClassPrototype(data)) return Sanitizer.formatClassPrototype(data);
98+
99+
// If values is a some class instance, it will be formatted as "<instance of SomeClass>"
100+
// new Editor() -> <instance of Editor>
101+
if (isClassInstance(data)) return Sanitizer.formatClassInstance(data);
102+
103+
// If values is an object, do recursive call
104+
if (isPlainObject(data)) return Sanitizer.sanitizeObject(data, depth + 1, seen);
105+
106+
// If values is a string, trim it for max-length
107+
if (isString(data)) return Sanitizer.trimString(data);
108+
109+
// If values is a number, boolean and other primitive, leave as is
101110
return data;
102111
}
103112

@@ -131,7 +140,9 @@ export default class Sanitizer {
131140
* @param depth - current depth of recursion
132141
* @param seen - Set of already seen objects to prevent circular references
133142
*/
134-
private static sanitizeObject(data: { [key: string]: any }, depth: number, seen: WeakSet<object>): Record<string, any> | '<deep object>' | '<big object>' {
143+
private static sanitizeObject(data: {
144+
[key: string]: any
145+
}, depth: number, seen: WeakSet<object>): Record<string, any> | '<deep object>' | '<big object>' {
135146
/**
136147
* If the maximum depth is reached, return a placeholder
137148
*/
@@ -157,72 +168,6 @@ export default class Sanitizer {
157168
return result;
158169
}
159170

160-
/**
161-
* Check if passed variable is an array
162-
*
163-
* @param target - variable to check
164-
*/
165-
private static isArray(target: any): boolean {
166-
return Array.isArray(target);
167-
}
168-
169-
/**
170-
* Check if passed variable is a not-constructed class
171-
*
172-
* @param target - variable to check
173-
*/
174-
private static isClassPrototype(target: any): boolean {
175-
if (!target || !target.constructor) {
176-
return false;
177-
}
178-
179-
/**
180-
* like
181-
* "function Function {
182-
* [native code]
183-
* }"
184-
*/
185-
const constructorStr = target.constructor.toString();
186-
187-
return constructorStr.includes('[native code]') && constructorStr.includes('Function');
188-
}
189-
190-
/**
191-
* Check if passed variable is a constructed class instance
192-
*
193-
* @param target - variable to check
194-
*/
195-
private static isClassInstance(target: any): boolean {
196-
return target && target.constructor && (/^class \S+ {/).test(target.constructor.toString());
197-
}
198-
199-
/**
200-
* Check if passed variable is a string
201-
*
202-
* @param target - variable to check
203-
*/
204-
private static isString(target: any): boolean {
205-
return typeof target === 'string';
206-
}
207-
208-
/**
209-
* Return string representation of the object type
210-
*
211-
* @param object - object to get type
212-
*/
213-
private static typeOf(object: any): string {
214-
return Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/)[1].toLowerCase();
215-
}
216-
217-
/**
218-
* Check if passed variable is an HTML Element
219-
*
220-
* @param target - variable to check
221-
*/
222-
private static isElement(target: any): boolean {
223-
return target instanceof Element;
224-
}
225-
226171
/**
227172
* Return name of a passed class
228173
*
@@ -248,31 +193,12 @@ export default class Sanitizer {
248193
*/
249194
private static trimString(target: string): string {
250195
if (target.length > Sanitizer.maxStringLen) {
251-
return target.substr(0, Sanitizer.maxStringLen) + '…';
196+
return target.substring(0, Sanitizer.maxStringLen) + '…';
252197
}
253198

254199
return target;
255200
}
256201

257-
/**
258-
* Represent HTML Element as string with it outer-html
259-
* HTMLDivElement -> "<div ...></div>"
260-
*
261-
* @param target - variable to format
262-
*/
263-
private static formatElement(target: Element): string {
264-
/**
265-
* Also, remove inner HTML because it can be BIG
266-
*/
267-
const innerHTML = target.innerHTML;
268-
269-
if (innerHTML) {
270-
return target.outerHTML.replace(target.innerHTML, '…');
271-
}
272-
273-
return target.outerHTML;
274-
}
275-
276202
/**
277203
* Represent not-constructed class as "<class SomeClass>"
278204
*

packages/core/src/utils/type-guards.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
13
/**
24
* Checks if value is a plain object (not null, array, Date, Map, etc.)
35
*
@@ -12,3 +14,55 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
1214

1315
return proto === Object.prototype || proto === null;
1416
}
17+
18+
/**
19+
* Check if passed variable is an array
20+
*
21+
* @param value - variable to check
22+
* @returns `true` if value is an array, otherwise `false`
23+
*/
24+
export function isArray(value: any): value is any[] {
25+
return Array.isArray(value);
26+
}
27+
28+
/**
29+
* Check if passed variable is a not-constructed class
30+
*
31+
* @param value - variable to check
32+
* @returns `true` if value is a class prototype, otherwise `false`
33+
*/
34+
export function isClassPrototype(value: any): boolean {
35+
if (!value || !value.constructor) {
36+
return false;
37+
}
38+
39+
/**
40+
* like
41+
* "function Function {
42+
* [native code]
43+
* }"
44+
*/
45+
const constructorStr = value.constructor.toString();
46+
47+
return constructorStr.includes('[native code]') && constructorStr.includes('Function');
48+
}
49+
50+
/**
51+
* Check if passed variable is a constructed class instance
52+
*
53+
* @param value - variable to check
54+
* @returns `true` if value is a class instance, otherwise `false`
55+
*/
56+
export function isClassInstance(value: any): boolean {
57+
return !!(value && value.constructor && (/^class \S+ {/).test(value.constructor.toString()));
58+
}
59+
60+
/**
61+
* Check if passed variable is a string
62+
*
63+
* @param value - variable to check
64+
* @returns `true` if value is a string, otherwise `false`
65+
*/
66+
export function isString(value: any): value is string {
67+
return typeof value === 'string';
68+
}

packages/javascript/tests/modules/sanitizer.test.ts renamed to packages/core/tests/modules/sanitizer.test.ts

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,7 @@
1-
import { describe, it, expect } from 'vitest';
2-
import Sanitizer from '../../src/modules/sanitizer';
1+
import { describe, expect, it } from 'vitest';
2+
import { Sanitizer } from '../../src';
33

44
describe('Sanitizer', () => {
5-
describe('isObject', () => {
6-
it('should return true for a plain object', () => {
7-
expect(Sanitizer.isObject({})).toBe(true);
8-
});
9-
10-
it('should return false for an array', () => {
11-
expect(Sanitizer.isObject([])).toBe(false);
12-
});
13-
14-
it('should return false for a string', () => {
15-
expect(Sanitizer.isObject('x')).toBe(false);
16-
});
17-
18-
it('should return false for a boolean', () => {
19-
expect(Sanitizer.isObject(true)).toBe(false);
20-
});
21-
22-
it('should return false for null', () => {
23-
expect(Sanitizer.isObject(null)).toBe(false);
24-
});
25-
26-
it('should return false for undefined', () => {
27-
expect(Sanitizer.isObject(undefined)).toBe(false);
28-
});
29-
});
30-
315
describe('sanitize', () => {
326
it('should pass through strings within the length limit', () => {
337
expect(Sanitizer.sanitize('hello')).toBe('hello');
@@ -85,14 +59,6 @@ describe('Sanitizer', () => {
8559
expect(result.a.b.c.d.e).toBe('<deep object>');
8660
});
8761

88-
it('should format HTML elements as a string starting with tag', () => {
89-
const el = document.createElement('div');
90-
const result = Sanitizer.sanitize(el);
91-
92-
expect(typeof result).toBe('string');
93-
expect(result).toMatch(/^<div/);
94-
});
95-
9662
it('should format a class (not constructed) as "<class Name>"', () => {
9763
class Foo {}
9864

0 commit comments

Comments
 (0)