/
palindrom.js
357 lines (322 loc) · 12.2 KB
/
palindrom.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
/*! Palindrom
* https://github.com/Palindrom/Palindrom
* (c) 2017 Joachim Wester
* MIT license
*/
import PalindromNetworkChannel from './palindrom-network-channel.js';
import { applyPatch, validate } from 'fast-json-patch/index.mjs';
import { JSONPatcherProxy } from 'jsonpatcherproxy';
import { JSONPatchQueue } from 'json-patch-queue';
import { JSONPatchOT } from 'json-patch-ot';
import { JSONPatchOTAgent } from 'json-patch-ot-agent';
import { PalindromError, PalindromConnectionError } from './palindrom-errors.js';
import Reconnector from './reconnector.js';
import NoQueue from './noqueue.js';
/* this variable is bumped automatically when you call npm version */
const palindromVersion = '6.4.0-0';
if (typeof global === 'undefined') {
if (typeof window !== 'undefined') {
/* incase neither window nor global existed, e.g React Native */
var global = window;
} else {
var global = {};
}
}
/**
* Defines a connection to a remote PATCH server, serves an object that is persistent between browser and server.
* @param {Object} [options] map of arguments. See README.md for description
*/
class Palindrom {
/**
* Palindrom version
*/
static get version() {
return palindromVersion;
}
constructor(options) {
/**
* Palindrom instance version
*/
this.version = palindromVersion;
if (typeof options !== 'object') {
throw new TypeError(
'Palindrom constructor requires an object argument.'
);
}
if (!options.remoteUrl) {
throw new TypeError('remoteUrl is required');
}
if (options.callback) {
console.warn(
'options.callback is deprecated. Please use `onStateReset` instead'
);
}
this.debug = options.debug != undefined ? options.debug : true;
this.isObserving = false;
function noop() {}
this.onLocalChange = options.onLocalChange || noop;
this.onRemoteChange = options.onRemoteChange || noop;
this.onStateReset = options.onStateReset || options.callback || noop;
this.filterLocalChange =
options.filterLocalChange || (operation => operation);
this.onPatchReceived = options.onPatchReceived || noop;
this.onPatchSent = options.onPatchSent || noop;
this.onSocketStateChanged = options.onSocketStateChanged || noop;
this.onConnectionError = options.onConnectionError || noop;
this.retransmissionThreshold = options.retransmissionThreshold || 3;
this.onReconnectionCountdown = options.onReconnectionCountdown || noop;
this.onReconnectionEnd = options.onReconnectionEnd || noop;
this.onSocketOpened = options.onSocketOpened || noop;
this.onIncomingPatchValidationError =
options.onIncomingPatchValidationError || noop;
this.onOutgoingPatchValidationError =
options.onOutgoingPatchValidationError || noop;
this.onError = options.onError || noop;
this.reconnector = new Reconnector(
() => this._connectToRemote(this.queue.pending),
this.onReconnectionCountdown,
this.onReconnectionEnd
);
this.network = new PalindromNetworkChannel(
this, // palindrom instance TODO: to be removed, used for error reporting
options.remoteUrl,
options.useWebSocket || false, // useWebSocket
this.handleRemoteChange.bind(this), //onReceive
this.onPatchSent.bind(this), //onSend,
this.onConnectionError.bind(this),
this.onSocketOpened.bind(this),
this.onSocketStateChanged.bind(this), //onStateChange
options.pingIntervalS
);
/**
* how many meta (OT) operations are there in each patch 0 or 2
*/
this.OTPatchIndexOffset = 0;
// choose queuing engine
if (options.localVersionPath && options.remoteVersionPath) {
// double versioning or OT
this.OTPatchIndexOffset = 2;
if (options.ot) {
this.queue = new JSONPatchOTAgent(
this.obj,
JSONPatchOT.transform,
[options.localVersionPath, options.remoteVersionPath],
this.validateAndApplySequence.bind(this),
options.purity
);
} else {
this.queue = new JSONPatchQueue(
this.obj,
[options.localVersionPath, options.remoteVersionPath],
this.validateAndApplySequence.bind(this),
options.purity
); // full or noop OT
}
} else {
// no queue - just api
this.queue = new NoQueue(
this.obj,
this.validateAndApplySequence.bind(this)
);
}
this._connectToRemote();
}
async _connectToRemote(reconnectionPendingData = null) {
const json = await this.network._establish(reconnectionPendingData);
this.reconnector.stopReconnecting();
if (this.debug) {
this.remoteObj = JSON.parse(JSON.stringify(json));
}
this.queue.reset(json);
}
get useWebSocket() {
return this.network.useWebSocket;
}
set useWebSocket(newValue) {
this.network.useWebSocket = newValue;
}
_sendPatch(patch) {
this.unobserve();
this.network.send(patch);
this.observe();
}
prepareProxifiedObject(obj) {
if (!obj) {
obj = {};
}
/* wrap a new object with a proxy observer */
this.jsonPatcherProxy = new JSONPatcherProxy(obj);
const proxifiedObj = this.jsonPatcherProxy.observe(false, operation => {
const filtered = this.filterLocalChange(operation);
// totally ignore falsy (didn't pass the filter) JSON Patch operations
filtered && this.handleLocalChange(filtered);
});
/* make it read-only and expose it as `obj` */
Object.defineProperty(this, 'obj', {
get() {
return proxifiedObj;
},
set() {
throw new Error('palindrom.obj is readonly');
},
/* so that we can redefine it */
configurable: true
});
/* JSONPatcherProxy default state is observing */
this.isObserving = true;
}
observe() {
this.jsonPatcherProxy && this.jsonPatcherProxy.resume();
this.isObserving = true;
}
unobserve() {
this.jsonPatcherProxy && this.jsonPatcherProxy.pause();
this.isObserving = false;
}
handleLocalChange(operation) {
const patch = [operation];
if (this.debug) {
this.validateSequence(this.remoteObj, patch);
}
this._sendPatch(this.queue.send(patch));
this.onLocalChange(patch);
}
validateAndApplySequence(tree, sequence) {
try {
// we don't want this changes to generate patches since they originate from server, not client
this.unobserve();
const results = applyPatch(tree, sequence, this.debug);
// notifications have to happen only where observe has been re-enabled
// otherwise some listener might produce changes that would go unnoticed
this.observe();
// the state was fully replaced
if (results.newDocument !== tree) {
// object was reset, proxify it again
this.prepareProxifiedObject(results.newDocument);
this.queue.obj = this.obj;
// validate json response
findRangeErrors(this.obj, this.onIncomingPatchValidationError);
// Catch errors in onStateReset
try {
this.onStateReset(this.obj);
} catch (error) {
// to prevent the promise's catch from swallowing errors inside onStateReset
this.onError(
new PalindromError(
`Error inside onStateReset callback: ${
error.message
}`
)
);
console.error(error);
}
}
this.onRemoteChange(sequence, results);
} catch (error) {
if (this.debug) {
this.onIncomingPatchValidationError(error);
return;
} else {
throw error;
}
}
return this.obj;
}
validateSequence(tree, sequence) {
const error = validate(sequence, tree);
if (error) {
this.onOutgoingPatchValidationError(error);
}
}
reconnectNow() {
this.reconnector.reconnectNow();
}
/**
* Callback to react on change received from remote.
* @see PalindromNetworkChannel.onReceive
*
* @param {JSONPatch} data single parsed JSON Patch (array of operations objects) that was send by remote.
* @param {String} url from which the change was issued
* @param {String} method HTTP method which resulted in this change ('GET' or 'PATCH') or 'WS' if came as Web Socket message
*/
handleRemoteChange(data, url, method) {
//TODO the below assertion should pass. However, some tests wrongly respond with an object instead of a patch
//console.assert(data instanceof Array, "expecting parsed JSON-Patch");
this.onPatchReceived(data, url, method);
const patch = data || []; // fault tolerance - empty response string should be treated as empty patch array
validateNumericsRangesInPatch(
patch,
this.onIncomingPatchValidationError,
this.OTPatchIndexOffset
);
if (patch.length === 0) {
// ping message
return;
}
// apply only if we're still watching
if (!this.isObserving) {
return;
}
this.queue.receive(patch);
if (
this.queue.pending &&
this.queue.pending.length &&
this.queue.pending.length > this.retransmissionThreshold
) {
// remote counterpart probably failed to receive one of earlier messages, because it has been receiving
// (but not acknowledging messages for some time
this.queue.pending.forEach(this._sendPatch, this);
}
if (this.debug) {
this.remoteObj = JSON.parse(JSON.stringify(this.obj));
}
}
/**
* Stops all networking, stops listeners, heartbeats, etc.
*/
stop() {
this.unobserve();
this.reconnector.stopReconnecting();
this.network.stop();
}
}
/**
* Iterates a JSON-Patch, traversing every patch value looking for out-of-range numbers
* @param {JSONPatch} patch patch to check
* @param {Function} errorHandler the error handler callback
* @param {*} startFrom the index where iteration starts
*/
function validateNumericsRangesInPatch(patch, errorHandler, startFrom) {
for (let i = startFrom, len = patch.length; i < len; i++) {
findRangeErrors(patch[i].value, errorHandler, patch[i].path);
}
}
/**
* Traverses/checks value looking for out-of-range numbers, throws a RangeError if it finds any
* @param {*} val value
* @param {Function} errorHandler
*/
function findRangeErrors(val, errorHandler, variablePath = '') {
const type = typeof val;
if (type == 'object') {
for (const key in val) {
if (val.hasOwnProperty(key)) {
findRangeErrors(
val[key],
errorHandler,
variablePath + '/' + key
);
}
}
} else if (
type === 'number' &&
(val > Number.MAX_SAFE_INTEGER || val < Number.MIN_SAFE_INTEGER)
) {
errorHandler(
new RangeError(
`A number that is either bigger than Number.MAX_INTEGER_VALUE or smaller than Number.MIN_INTEGER_VALUE has been encountered in a patch, value is: ${val}, variable path is: ${variablePath}`
)
);
}
}
export {Palindrom};