Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/client/lib/ObjectObserver.ts
1028 views
1
/**
2
ISC License (ISC)
3
4
Copyright 2015 Yuri Guller ([email protected])
5
Modifications 2021 Data Liberation Foundation
6
7
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted,
8
provided that the above copyright notice and this permission notice appear in all copies.
9
10
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
11
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS.
12
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES
13
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
14
NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15
*/
16
import IObservableChange, {
17
ObservableChangeType,
18
} from '@secret-agent/interfaces/IObservableChange';
19
20
export default class ObjectObserver implements ProxyHandler<any> {
21
private static key = Symbol.for('object-observer-key-v0');
22
23
public onChanges: (changes: IObservableChange[]) => void;
24
public readonly target: any;
25
public readonly proxy: any;
26
27
public get path(): PropertyKey[] {
28
const path = [];
29
if (this.parentPath.length) path.push(...this.parentPath);
30
if (this.ownKey !== undefined && this.ownKey !== null) path.push(this.ownKey);
31
return path;
32
}
33
34
public ownKey: PropertyKey;
35
36
private readonly isArray: boolean = false;
37
private parentPath: PropertyKey[] = [];
38
39
private readonly proxiedArrayMethods = {
40
pop: this.pop,
41
push: this.push,
42
shift: this.shift,
43
unshift: this.unshift,
44
reverse: this.reverse,
45
sort: this.sort,
46
fill: this.fill,
47
copyWithin: this.copyWithin,
48
splice: this.splice,
49
};
50
51
constructor(
52
source: any,
53
onChanges?: ObjectObserver['onChanges'],
54
ownKey: PropertyKey = null,
55
parentPath: PropertyKey[] = [],
56
) {
57
if (!source || typeof source !== 'object') {
58
throw new Error('Observable MAY ONLY be created from a non-null object');
59
}
60
61
if (ArrayBuffer.isView(source) || Buffer.isBuffer(source) || source instanceof Date) {
62
throw new Error('Observable cannot be a Buffer or Date');
63
}
64
65
this.ownKey = ownKey;
66
this.parentPath = parentPath;
67
this.onChanges = onChanges;
68
this.isArray = Array.isArray(source);
69
70
const target = this.isArray ? [] : {};
71
for (const [key, value] of Object.entries(source)) {
72
const storedKey = this.coerceKey(key);
73
target[storedKey] = this.observeChild(value, storedKey);
74
}
75
76
Object.setPrototypeOf(target, Object.getPrototypeOf(source));
77
78
Object.defineProperty(target, ObjectObserver.key, {
79
configurable: true,
80
value: this,
81
});
82
this.proxy = new Proxy(target, this);
83
this.target = target;
84
}
85
86
emit(...changes: IObservableChange[]): void {
87
if (!changes.length || !this.onChanges) return;
88
for (const change of changes) {
89
change.value = this.deepClone(change.value);
90
}
91
92
this.onChanges(changes);
93
}
94
95
detach(): any {
96
delete this.target[ObjectObserver.key];
97
return this.target;
98
}
99
100
set(target: any, key: PropertyKey, value: any): boolean {
101
const oldValue = target[key];
102
if (value !== oldValue) {
103
key = this.coerceKey(key);
104
value = this.observeChild(value, key);
105
target[key] = value;
106
ObjectObserver.detach(oldValue);
107
108
const type =
109
oldValue === undefined ? ObservableChangeType.insert : ObservableChangeType.update;
110
const path = [...this.path, key];
111
112
this.emit({ type, path, value });
113
}
114
115
return true;
116
}
117
118
get(target: any, key: PropertyKey): any {
119
if (typeof target[key] === 'function') {
120
if (this.proxiedArrayMethods.hasOwnProperty(key) && this.isArray) {
121
return this.proxiedArrayMethods[key].bind(this);
122
}
123
124
return target[key].bind(target);
125
}
126
return target[key];
127
}
128
129
deleteProperty(target: any, key: PropertyKey): boolean {
130
ObjectObserver.detach(target[key]);
131
132
delete target[key];
133
134
key = this.coerceKey(key);
135
this.emit({ type: ObservableChangeType.delete, path: [...this.path, key] });
136
137
return true;
138
}
139
140
deepClone(object: any): any {
141
if (!object) return object;
142
const type = typeof object;
143
if (type === 'string' || type === 'number' || type === 'boolean') return object;
144
145
if (type === 'object') {
146
if (Array.isArray(object)) {
147
return object.map(this.deepClone.bind(this));
148
}
149
const result: any = {};
150
for (const [key, value] of Object.entries(object)) {
151
result[key] = this.deepClone(value);
152
}
153
return result;
154
}
155
return object;
156
}
157
158
serialize(target): string {
159
if (!target) return target;
160
if (Buffer.isBuffer(target)) {
161
return target.toString('base64');
162
}
163
if (ArrayBuffer.isView(target)) {
164
return Buffer.from(target.buffer).toString('base64');
165
}
166
if (target instanceof Date) {
167
return target.toISOString();
168
}
169
return target;
170
}
171
172
toJSON(): any {
173
return {
174
path: this.path,
175
};
176
}
177
178
// /// PROXIED ARRAY FUNCTIONS
179
180
private pop<T>(): T {
181
const target = this.target as Array<T>;
182
const poppedIndex = target.length - 1;
183
184
const popResult = ObjectObserver.detach(target.pop());
185
186
this.emit({
187
type: ObservableChangeType.delete,
188
path: [...this.path, poppedIndex],
189
});
190
191
return popResult;
192
}
193
194
private push<T>(...items: T[]): number {
195
const target = this.target as Array<T>;
196
197
const initialLength = target.length;
198
const changes: IObservableChange[] = [];
199
200
items = items.map((x, i) => {
201
const value = this.observeChild(x, i + initialLength);
202
changes.push({
203
type: ObservableChangeType.insert,
204
path: [...this.path, i + initialLength],
205
value,
206
});
207
return value;
208
});
209
210
const pushResult = target.push(...items);
211
212
this.emit(...changes);
213
214
return pushResult;
215
}
216
217
private shift<T>(): T {
218
const target = this.target as Array<T>;
219
220
const shiftResult = ObjectObserver.detach(target.shift());
221
this.updateArrayIndices();
222
223
this.emit({
224
type: ObservableChangeType.delete,
225
path: [...this.path, 0],
226
});
227
228
return shiftResult;
229
}
230
231
private unshift<T>(...items: T[]): number {
232
const target = this.target as Array<T>;
233
234
const changes: IObservableChange[] = new Array(items.length);
235
items = items.map((x, i) => {
236
const value = this.observeChild(x, i);
237
changes[i] = { type: ObservableChangeType.insert, path: [i], value };
238
return value;
239
});
240
241
const unshiftResult = target.unshift(...items);
242
this.updateArrayIndices();
243
244
this.emit(...changes);
245
246
return unshiftResult;
247
}
248
249
private reverse<T>(): T[] {
250
const target = this.target as Array<T>;
251
252
const prev = [...target];
253
254
target.reverse();
255
const newOrder = this.getNewSortOrder(prev);
256
257
this.emit({
258
type: ObservableChangeType.reorder,
259
path: this.path,
260
value: newOrder,
261
});
262
263
return this.proxy;
264
}
265
266
private sort<T>(comparator?: (a: T, b: T) => number): T[] {
267
const target = this.target as Array<T>;
268
269
const prev = [...target];
270
271
target.sort(comparator);
272
const newOrder = this.getNewSortOrder(prev);
273
274
this.emit({
275
type: ObservableChangeType.reorder,
276
path: this.path,
277
value: newOrder,
278
});
279
280
return this.proxy;
281
}
282
283
private copyWithin<T>(insertIndex: number, copyStart: number, copyEnd?: number): T[] {
284
const target = this.target as Array<T>;
285
const length = target.length;
286
287
if (insertIndex < 0) insertIndex = Math.max(length + insertIndex, 0);
288
289
copyStart = copyStart ?? 0;
290
if (copyStart < 0) copyStart = Math.max(length + copyStart, 0);
291
if (copyStart > length) copyStart = length;
292
293
copyEnd = copyEnd ?? length;
294
if (copyEnd < 0) copyEnd = Math.max(length + copyEnd, 0);
295
if (copyEnd > length) copyEnd = length;
296
297
const itemCount = Math.min(copyEnd - copyStart, length - insertIndex);
298
299
if (insertIndex < length && insertIndex !== copyStart && itemCount > 0) {
300
const prev = [...target];
301
const changes: IObservableChange[] = [];
302
303
target.copyWithin(insertIndex, copyStart, copyEnd);
304
305
for (let i = insertIndex; i < insertIndex + itemCount; i += 1) {
306
// detach overridden observables, if any
307
const previousItem = ObjectObserver.detach(prev[i]);
308
ObjectObserver.detach(target[i]);
309
// update newly placed observables, if any
310
const item = this.observeChild(target[i], i);
311
target[i] = item;
312
313
if (typeof item !== 'object' && item === previousItem) {
314
continue;
315
}
316
changes.push({ type: ObservableChangeType.update, path: [...this.path, i], value: item });
317
}
318
this.updateArrayIndices();
319
320
this.emit(...changes);
321
}
322
323
return this.proxy;
324
}
325
326
private splice<T>(start: number, deleteCount: number, ...items: T[]): T[] {
327
const target = this.target as Array<T>;
328
const startLength = target.length;
329
330
items = items.map(this.observeChild.bind(this));
331
332
const args: any[] = [deleteCount, ...items];
333
if (args.length === 1 && deleteCount === undefined) {
334
args.length = 0;
335
}
336
337
const deletedItems = target.splice(start, ...args);
338
339
this.updateArrayIndices();
340
341
for (const deleted of deletedItems) {
342
ObjectObserver.detach(deleted);
343
}
344
345
let startIndex = start ?? 0;
346
if (startIndex < 0) startIndex += startLength;
347
348
const deleteOrUpdateCount = deleteCount ?? startLength - startIndex;
349
350
const changes: IObservableChange[] = [];
351
let changeCount = 0;
352
while (changeCount < deleteOrUpdateCount) {
353
const index = startIndex + changeCount;
354
if (changeCount < items.length) {
355
changes.push({
356
type: ObservableChangeType.update,
357
path: [...this.path, index],
358
value: target[index],
359
});
360
} else {
361
changes.push({
362
type: ObservableChangeType.delete,
363
path: [...this.path, index],
364
});
365
}
366
changeCount += 1;
367
}
368
369
while (changeCount < items.length) {
370
const index = startIndex + changeCount;
371
changes.push({
372
type: ObservableChangeType.insert,
373
path: [...this.path, index],
374
value: target[index],
375
});
376
changeCount += 1;
377
}
378
this.emit(...changes);
379
380
return deletedItems;
381
}
382
383
private fill<T>(filVal: any, start: number, end?: number): T[] {
384
const target = this.target as Array<T>;
385
const prev = [...target];
386
387
target.fill(filVal, start, end);
388
389
const changes: IObservableChange[] = [];
390
for (let i = 0; i < target.length; i += 1) {
391
target[i] = this.observeChild(target[i], i);
392
393
if (prev[i] !== target[i]) {
394
const type = i in prev ? ObservableChangeType.update : ObservableChangeType.insert;
395
if (i in prev) ObjectObserver.detach(prev[i]);
396
397
changes.push({ type, path: [...this.path, i], value: target[i] });
398
}
399
}
400
if (changes.length) this.emit(...changes);
401
402
return this.proxy;
403
}
404
405
private getNewSortOrder<T>(previousArray: T[]): number[] {
406
const target = this.target as Array<T>;
407
const previousOrder: number[] = new Array(target.length);
408
const lastItemIndices = new Map<any, number>();
409
// reindex the paths
410
for (let i = 0; i < target.length; i += 1) {
411
const item = target[i];
412
if (item && typeof item === 'object') {
413
const observable = item[ObjectObserver.key] as ObjectObserver;
414
previousOrder[i] = observable.ownKey as number;
415
// record new ownKey
416
observable.ownKey = i;
417
} else {
418
// if primitive, need to progress through the array
419
previousOrder[i] = previousArray.indexOf(item, (lastItemIndices.get(item) ?? -1) + 1);
420
lastItemIndices.set(item, previousOrder[i]);
421
}
422
}
423
return previousOrder;
424
}
425
426
private updateArrayIndices(): void {
427
const target = this.target as Array<any>;
428
// reindex the paths
429
for (let i = 0; i < target.length; i += 1) {
430
const item = target[i];
431
const observer = item[ObjectObserver.key] as ObjectObserver;
432
if (observer) observer.ownKey = i;
433
}
434
}
435
436
private observeChild(item: any, key: PropertyKey): any {
437
if (!item || typeof item !== 'object') return item;
438
439
if (Buffer.isBuffer(item)) {
440
return item.toString('base64');
441
}
442
if (ArrayBuffer.isView(item)) {
443
return Buffer.from(item.buffer).toString('base64');
444
}
445
if (item instanceof Date) {
446
return item.toISOString();
447
}
448
449
const existing = item[ObjectObserver.key] as ObjectObserver;
450
if (existing) {
451
existing.ownKey = key;
452
existing.parentPath = this.path;
453
existing.onChanges = this.onChanges;
454
return existing.proxy;
455
}
456
457
const observable = new ObjectObserver(item, this.onChanges, key, this.path);
458
459
return observable.proxy;
460
}
461
462
private coerceKey(key: PropertyKey): PropertyKey {
463
if (this.isArray && !Number.isInteger(key) && isNumberRegex.test(key as string)) {
464
return parseInt(key as string, 10);
465
}
466
return key;
467
}
468
469
public static create<T>(target: T, onChanges?: (changes: IObservableChange[]) => any): T {
470
const observable = new ObjectObserver(target, onChanges);
471
return observable.proxy;
472
}
473
474
public static isObserved(item: any): boolean {
475
return !!item[ObjectObserver.key];
476
}
477
478
private static detach<T>(item: T): T {
479
if (item && typeof item === 'object') {
480
const existing = item[ObjectObserver.key] as ObjectObserver;
481
if (existing) return existing.detach();
482
}
483
return item;
484
}
485
}
486
487
export function Observable<T>(source: T): T {
488
const observable = new ObjectObserver(source);
489
return observable.proxy;
490
}
491
492
const isNumberRegex = /^\d+$/;
493
494