Skip to content

Commit ccf2f3d

Browse files
authored
feat: Use proper object serializer to handle cyclical objects (#1410)
1 parent 1120c08 commit ccf2f3d

File tree

3 files changed

+280
-7
lines changed

3 files changed

+280
-7
lines changed

packages/utils/src/object.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,85 @@
1+
/**
2+
* Just an Error object with arbitrary attributes attached to it.
3+
*/
4+
interface ExtendedError extends Error {
5+
[key: string]: any;
6+
}
7+
8+
/**
9+
* Transforms Error object into an object literal with all it's attributes
10+
* attached to it.
11+
*
12+
* Based on: https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106
13+
*
14+
* @param error An Error containing all relevant information
15+
* @returns An object with all error properties
16+
*/
17+
function objectifyError(error: ExtendedError): object {
18+
// These properties are implemented as magical getters and don't show up in `for-in` loop
19+
const err: {
20+
stack: string | undefined;
21+
message: string;
22+
name: string;
23+
[key: string]: any;
24+
} = {
25+
message: error.message,
26+
name: error.name,
27+
stack: error.stack,
28+
};
29+
30+
for (const i in error) {
31+
if (Object.prototype.hasOwnProperty.call(error, i)) {
32+
err[i] = error[i];
33+
}
34+
}
35+
36+
return err;
37+
}
38+
39+
/**
40+
* Serializer function used as 2nd argument to JSON.serialize in `serialize()` util function.
41+
*/
42+
function serializer(): (key: string, value: any) => any {
43+
const stack: any[] = [];
44+
const keys: string[] = [];
45+
const cycleReplacer = (_: string, value: any) => {
46+
if (stack[0] === value) {
47+
return '[Circular ~]';
48+
}
49+
return `[Circular ~.${keys.slice(0, stack.indexOf(value)).join('.')}]`;
50+
};
51+
52+
return function(this: any, key: string, value: any): any {
53+
let currentValue: any = value;
54+
55+
if (stack.length > 0) {
56+
const thisPos = stack.indexOf(this);
57+
58+
if (thisPos !== -1) {
59+
stack.splice(thisPos + 1);
60+
keys.splice(thisPos, Infinity, key);
61+
} else {
62+
stack.push(this);
63+
keys.push(key);
64+
}
65+
66+
if (stack.indexOf(currentValue) !== -1) {
67+
currentValue = cycleReplacer.call(this, key, currentValue);
68+
}
69+
} else {
70+
stack.push(currentValue);
71+
}
72+
73+
return currentValue instanceof Error
74+
? objectifyError(currentValue)
75+
: currentValue;
76+
};
77+
}
78+
179
/**
280
* Serializes the given object into a string.
81+
* Like JSON.stringify, but doesn't throw on circular references.
82+
* Based on a `json-stringify-safe` package and modified to handle Errors serialization.
383
*
484
* The object must be serializable, i.e.:
585
* - Only primitive types are allowed (object, array, number, string, boolean)
@@ -9,8 +89,7 @@
989
* @returns A string containing the serialized object.
1090
*/
1191
export function serialize<T>(object: T): string {
12-
// TODO: Fix cyclic and deep objects
13-
return JSON.stringify(object);
92+
return JSON.stringify(object, serializer());
1493
}
1594

1695
/**
@@ -21,7 +100,6 @@ export function serialize<T>(object: T): string {
21100
* @returns The deserialized object.
22101
*/
23102
export function deserialize<T>(str: string): T {
24-
// TODO: Handle recursion stubs from serialize
25103
return JSON.parse(str) as T;
26104
}
27105

@@ -59,9 +137,7 @@ export function fill(
59137
): void {
60138
const orig = source[name];
61139
source[name] = replacement(orig);
62-
// tslint:disable:no-unsafe-any
63140
source[name].__raven__ = true;
64-
// tslint:disable:no-unsafe-any
65141
source[name].__orig__ = orig;
66142
if (track) {
67143
track.push([source, name, orig]);

packages/utils/test/object.test.ts

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ const MATRIX = [
66
{ name: 'string', object: 'test', serialized: '"test"' },
77
{ name: 'array', object: [1, 'test'], serialized: '[1,"test"]' },
88
{ name: 'object', object: { a: 'test' }, serialized: '{"a":"test"}' },
9-
// TODO: Add tests for cyclic and deep objects
109
];
1110

1211
describe('clone()', () => {
@@ -18,11 +17,207 @@ describe('clone()', () => {
1817
});
1918

2019
describe('serialize()', () => {
20+
function jsonify(obj: object): string {
21+
return JSON.stringify(obj);
22+
}
23+
2124
for (const entry of MATRIX) {
2225
test(`serializes a ${entry.name}`, () => {
2326
expect(serialize(entry.object)).toEqual(entry.serialized);
2427
});
2528
}
29+
30+
describe('cyclical structures', () => {
31+
it('must stringify circular objects', () => {
32+
const obj = { name: 'Alice' };
33+
// @ts-ignore
34+
obj.self = obj;
35+
36+
const json = serialize(obj);
37+
expect(json).toEqual(jsonify({ name: 'Alice', self: '[Circular ~]' }));
38+
});
39+
40+
it('must stringify circular objects with intermediaries', () => {
41+
const obj = { name: 'Alice' };
42+
// @ts-ignore
43+
obj.identity = { self: obj };
44+
const json = serialize(obj);
45+
expect(json).toEqual(
46+
jsonify({ name: 'Alice', identity: { self: '[Circular ~]' } }),
47+
);
48+
});
49+
50+
it('must stringify circular objects deeper', () => {
51+
const obj = { name: 'Alice', child: { name: 'Bob' } };
52+
// @ts-ignore
53+
obj.child.self = obj.child;
54+
55+
expect(serialize(obj)).toEqual(
56+
jsonify({
57+
name: 'Alice',
58+
child: { name: 'Bob', self: '[Circular ~.child]' },
59+
}),
60+
);
61+
});
62+
63+
it('must stringify circular objects deeper with intermediaries', () => {
64+
const obj = { name: 'Alice', child: { name: 'Bob' } };
65+
// @ts-ignore
66+
obj.child.identity = { self: obj.child };
67+
68+
expect(serialize(obj)).toEqual(
69+
jsonify({
70+
name: 'Alice',
71+
child: { name: 'Bob', identity: { self: '[Circular ~.child]' } },
72+
}),
73+
);
74+
});
75+
76+
it('must stringify circular objects in an array', () => {
77+
const obj = { name: 'Alice' };
78+
// @ts-ignore
79+
obj.self = [obj, obj];
80+
81+
expect(serialize(obj)).toEqual(
82+
jsonify({
83+
name: 'Alice',
84+
self: ['[Circular ~]', '[Circular ~]'],
85+
}),
86+
);
87+
});
88+
89+
it('must stringify circular objects deeper in an array', () => {
90+
const obj = {
91+
name: 'Alice',
92+
children: [{ name: 'Bob' }, { name: 'Eve' }],
93+
};
94+
// @ts-ignore
95+
obj.children[0].self = obj.children[0];
96+
// @ts-ignore
97+
obj.children[1].self = obj.children[1];
98+
99+
expect(serialize(obj)).toEqual(
100+
jsonify({
101+
name: 'Alice',
102+
children: [
103+
{ name: 'Bob', self: '[Circular ~.children.0]' },
104+
{ name: 'Eve', self: '[Circular ~.children.1]' },
105+
],
106+
}),
107+
);
108+
});
109+
110+
it('must stringify circular arrays', () => {
111+
const obj: object[] = [];
112+
obj.push(obj);
113+
obj.push(obj);
114+
const json = serialize(obj);
115+
expect(json).toEqual(jsonify(['[Circular ~]', '[Circular ~]']));
116+
});
117+
118+
it('must stringify circular arrays with intermediaries', () => {
119+
const obj: object[] = [];
120+
obj.push({ name: 'Alice', self: obj });
121+
obj.push({ name: 'Bob', self: obj });
122+
123+
expect(serialize(obj)).toEqual(
124+
jsonify([
125+
{ name: 'Alice', self: '[Circular ~]' },
126+
{ name: 'Bob', self: '[Circular ~]' },
127+
]),
128+
);
129+
});
130+
131+
it('must stringify repeated objects in objects', () => {
132+
const obj = {};
133+
const alice = { name: 'Alice' };
134+
// @ts-ignore
135+
obj.alice1 = alice;
136+
// @ts-ignore
137+
obj.alice2 = alice;
138+
139+
expect(serialize(obj)).toEqual(
140+
jsonify({
141+
alice1: { name: 'Alice' },
142+
alice2: { name: 'Alice' },
143+
}),
144+
);
145+
});
146+
147+
it('must stringify repeated objects in arrays', () => {
148+
const alice = { name: 'Alice' };
149+
const obj = [alice, alice];
150+
const json = serialize(obj);
151+
expect(json).toEqual(jsonify([{ name: 'Alice' }, { name: 'Alice' }]));
152+
});
153+
154+
it('must stringify error objects, including extra properties', () => {
155+
const obj = new Error('Wubba Lubba Dub Dub');
156+
// @ts-ignore
157+
obj.reason = new TypeError("I'm pickle Riiick!");
158+
// @ts-ignore
159+
obj.extra = 'some extra prop';
160+
161+
// Stack is inconsistent across browsers, so override it and just make sure its stringified
162+
obj.stack = 'x';
163+
// @ts-ignore
164+
obj.reason.stack = 'x';
165+
166+
// IE 10/11
167+
// @ts-ignore
168+
delete obj.description;
169+
// @ts-ignore
170+
delete obj.reason.description;
171+
172+
// Safari doesn't allow deleting those properties from error object, yet only it provides them
173+
const result = serialize(obj)
174+
.replace(/ +"(line|column|sourceURL)": .+,?\n/g, '')
175+
.replace(/,\n( +)}/g, '\n$1}'); // make sure to strip trailing commas as well
176+
177+
expect(result).toEqual(
178+
jsonify({
179+
message: 'Wubba Lubba Dub Dub',
180+
name: 'Error',
181+
stack: 'x',
182+
reason: {
183+
message: "I'm pickle Riiick!",
184+
name: 'TypeError',
185+
stack: 'x',
186+
},
187+
extra: 'some extra prop',
188+
}),
189+
);
190+
});
191+
});
192+
193+
it('must stringify error objects with circular references', () => {
194+
const obj = new Error('Wubba Lubba Dub Dub');
195+
// @ts-ignore
196+
obj.reason = obj;
197+
198+
// Stack is inconsistent across browsers, so override it and just make sure its stringified
199+
obj.stack = 'x';
200+
// @ts-ignore
201+
obj.reason.stack = 'x';
202+
203+
// IE 10/11
204+
// @ts-ignore
205+
delete obj.description;
206+
207+
// Safari doesn't allow deleting those properties from error object, yet only it provides them
208+
const result = serialize(obj)
209+
.replace(/ +"(line|column|sourceURL)": .+,?\n/g, '')
210+
.replace(/,\n( +)}/g, '\n$1}'); // make sure to strip trailing commas as well
211+
212+
expect(result).toEqual(
213+
jsonify({
214+
message: 'Wubba Lubba Dub Dub',
215+
name: 'Error',
216+
stack: 'x',
217+
reason: '[Circular ~]',
218+
}),
219+
);
220+
});
26221
});
27222

28223
describe('deserialize()', () => {

packages/utils/test/tslint.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"completed-docs": false,
55
"no-unused-expression": false,
66
"no-implicit-dependencies": [true, "dev"],
7-
"no-unsafe-any": false
7+
"no-unsafe-any": false,
8+
// We disable this rule, because order in `serialize()` tests matter
9+
"object-literal-sort-keys": false
810
}
911
}

0 commit comments

Comments
 (0)