forked from ably/demo-json-patch
-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.html
428 lines (380 loc) · 18 KB
/
index.html
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
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
<html>
<head>
<script type="text/javascript">
/*
---- INSTRUCTIONS TO GET THIS DEMO WORKING ----
1. Go to https://www.ably.io/ and sign up for a free API key.
2. Then enable history for the namespace "json-patch", see https://goo.gl/LFbwXj
3. Insert your API key below where it says "[INSERT_API_KEY_HERE]"
4. Open this in your browser, and click "Start publish"
5. Open this page in other tabs to see them all keep in sync in real time
*/
/* Not in most circumstances we advise against the use of API keys by clients.
However for simplicity for this demo, we are using API keys. See https://goo.gl/bJFBAf */
const ApiKey = "[INSERT_API_KEY_HERE]";
if (ApiKey.indexOf('INSERT') >= 0) {
alert("Oops, looks like you've not set up your API key and enabled history either.\n\nFollow the simple instructions at https://github.com/ably/demo-json-patch to get this demo working.\n\nTaking you there now.");
document.location.href = 'https://github.com/ably/demo-json-patch';
}
</script>
<script src="https://files.ably.io/javascript/json-patch-duplex.min.js"></script>
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
<script src="https://cdn.ably.io/lib/ably.min-0.9.js"></script>
<style>
body { font-family: Arial, Helvetica, sans; }
.panel { width: 50%; float: left; padding: 0 5px; box-sizing: border-box; }
textarea.object { width: 100%; height: 22em; font-size: 10px; font-family: monospace; }
textarea.log { width: 100%; height: 30em; font-size: 10px; font-family: monospace; }
#publisher-btn { font-size: 12px; padding-left: 1em; font-weight: normal; }
.board-size, #byte-stats { font-size: 12px; padding-left: 2em; font-weight: normal; }
.notice { text-align: center; color: red; padding-top: 2em; clear: both; }
.intro { text-align: center; font-size: 14px; margin-bottom: 5px; padding: 5px 0 15px; border-bottom: 1px solid #CCC; }
.ably-logo { float: right; width: 35px; height: 35px; margin-top: 5px;}
.github-logo { width: 16px; height: 16px; position: relative; top: 2px; }
a { color: #ED760A; text-decoration: none; }
a:hover { color: #F9A01B; }
</style>
</head>
<body>
<p class="intro">
You are viewing the Ably JSON Patch demo. Not sure what this is, <a href="https://github.com/ably/demo-json-patch">find out more on Github <img src="/GitHub-Mark-32px.png" alt="Github" class="github-logo"></a>
</p>
<div class="panel">
<h3>Publisher Object
<a href="#" id="publisher-btn">Start publishing</a>
<span class="board-size">Max objects: <select id="board-size"></select></span>
</h3>
<textarea id="publisher-object" class="object">Press start publishing link to start publishign JSON data.
Note: If two publishers are publishing simultaneously all bets are off - this will fail as the serial numbers will conflict!
</textarea>
<h4>Log <span id="byte-stats"></span></h4>
<textarea id="publisher-log" class="log"></textarea>
</div>
<div class="panel">
<a href="https://www.ably.io"><img src="https://files.ably.io/logo-70x70.png" alt="Ably logo" class="ably-logo"></a>
<h3>Client Object</h3>
<textarea id="client-object" class="object"></textarea>
<h4>Log</h4>
<textarea id="client-log" class="log"></textarea>
</div>
</body>
<script type="text/javascript">
const Names = 'James John Robert Michael William David Richard Charles Joseph Mary Patricia Linda Barbara Elizabeth Jennifer Maria Susan Margaret Mark Donald George Kenneth Steven Edward Lisa Nancy Karen Betty Jelen Sandra Donna Carol Ruth Sharon'.split(' ');
const Defaults = { score: 50, boardLength: 8 }
/* Scoreboard is an example JSON object used to demonstrate use of JSON patch */
const scoreBoard = {}
randomlyChangeScoreBoard(Defaults.boardLength);
/* Channel name in the "json-patch" namespace which must be configured with history enabled, see https://goo.gl/LFbwXj */
const channel = `json-patch:demo`;
const publisher = new Ably.Realtime({ key: ApiKey });
const subscriber = new Ably.Realtime({ key: ApiKey });
const publisherChannel = publisher.channels.get(channel);
const publisherSession = String(Math.random()); /* Used to detect multiple concurrent publishers */
const subscriberChannel = subscriber.channels.get(channel);
/* configuration for PatchService
If 60 seconds has elapsed, resend the full object
Every 100 JsonPatches, send the full object again.
Feel free to tweak this for your own use case */
const SendFullObject = { afterSeconds: 60, every: 100 };
/* ------ PUBLISHER LOGIC -------- */
/*
PatchService is responsible for generating JSON patch objects with meta data
that is then simply published over Ably
*/
class PatchService {
constructor(resendAfterSeconds, resendEvery) {
this.serial = 0;
this.resendAfterTime = resendAfterSeconds * 1000;
this.resendEvery = resendEvery;
this.jsonObject = {};
this.lastObjectSent = { serial: -this.resendEvery, time: new Date().getTime() }; /* Force full object resend on first send */
}
update(newObject, force) {
let oldObject = this.jsonObject;
let payload = { serial: this.serial };
/* If last full object serials equals resendEvery or more than resendAfterTime has passed,
then resend the entire object */
if (!force &&
(this.serial - this.lastObjectSent.serial != this.resendEvery) &&
(this.lastObjectSent.time > new Date().getTime() - this.resendAfterTime)) {
let patch = jsonpatch.compare(oldObject, newObject)
if (patch.length === 0) {
/* nothing changed */
return null;
}
payload.patch = patch;
payload.root = this.lastObjectSent.serial;
}
let newObjectSize = JSON.stringify(newObject).length,
patchSize = (JSON.stringify(payload.patch) + payload.root).length;
/* Send full object if patch is larger than the actual object for bandwith efficiency */
if (!payload.patch || (patchSize >= newObjectSize)) {
this.lastObjectSent = { serial: this.serial, time: new Date().getTime() };
payload.object = newObject;
delete payload.patch;
delete payload.root;
}
this.jsonObject = $.extend(true, {}, newObject); /* note using jQuery clone to ensure mutations on the object subsequently do not affect the diff */
this.lastUpdatedAt = new Date().getTime();
this.serial += 1;
return payload;
}
}
let patchService = new PatchService(SendFullObject.afterSeconds, SendFullObject.every);
let totalBytesSaved = 0, totalBytes = 0;
let $byteStats = $('#byte-stats');
function updateAndPublishScores() {
randomlyChangeScoreBoard();
$('#publisher-object').val(JSON.stringify(scoreBoard, null, 2));
let update = patchService.update(scoreBoard);
if (update === null) {
log('publisher', 'Skipping patch as no change');
} else {
publisherChannel.publish(publisherSession, update, function(err) {
if (err) {
log('publisher', `Failed to publish, will force resend the entire object. Err: ${JSON.stringify(err)}`);
patchService.update(scoreBoard, true);
} else {
let payloadBytes = JSON.stringify(update.patch || update.object).length,
objectBytes = JSON.stringify(scoreBoard).length,
savedBytes = objectBytes - payloadBytes,
savedPct = Math.round((1 - (payloadBytes / objectBytes)) * 100);
log('publisher', `Published ${update.object ? 'full object' : 'json patch'}, serial: ${update.serial}, size: ${payloadBytes} bytes${update.object ? '' : `, saved: ${savedBytes} bytes (${savedPct}%)`}`);
totalBytes += objectBytes;
if (update.patch && savedBytes) {
totalBytesSaved += savedBytes;
let saving = Math.round((totalBytesSaved / totalBytes) * 100);
$byteStats.text(`Bandwidth: w/o patch ${fileSizeSI(totalBytes)}, with patch ${fileSizeSI(totalBytes - totalBytesSaved)}, saving ${saving}%`);
}
}
});
}
}
/* Manager start & stop publishign events at an interval */
let publishInterval;
function stopPublishing() {
clearInterval(publishInterval);
publishInterval = null;
$('#publisher-btn').text('Start publishing');
log('publisher', 'Stopped publishing');
}
function startPublishing() {
publishInterval = setInterval(() => {
updateAndPublishScores();
}, 2000);
updateAndPublishScores();
$('#publisher-btn').text('Stop publishing');
log('publisher', 'Started publishing');
}
function isPublishing() {
return publishInterval;
}
/* UI logging for publisher connection and attach events */
publisher.connection.once('connected', function() {
log('publisher', 'connected to Ably');
publisherChannel.attach(function(err) {
if (err) {
log('publisher', `Could not attach to channel ${channel}. Error: ${JSON.stringify(err)}`);
return;
}
log('publisher', `Attached to channel ${channel}`);
});
publisherChannel.subscribe((msg) => {
if (isPublishing()) {
if (msg.name != publisherSession) {
alert ("Error! Another publisher is publishing events to this channel. This will not work as the serial numbers will not be consistent and patches cannot be applied. Stopping this client from publishing now");
stopPublishing();
}
}
});
});
/* ---- SUBSCRIBER LOGIC ------ */
subscriber.connection.once('connected', () => {
log('client', 'connected to Ably');
let patchBacklog = [], readyForPatches = false;
let jsonObject = {}
/* Apply JSON patch or complete JSON object update received from the Ably channel */
let applyUpdate = (update) => {
if (update.object) {
log('client', `Replaced object with serial ${update.serial}`);
jsonObject = update.object;
} else {
log('client', `Applied patch with serial ${update.serial}, ${update.patch.length} operation(s)`);
try {
jsonpatch.apply(jsonObject, update.patch);
} catch (e) {
log('client', `Error applying patch, this update cannot be applied. Update: ${JSON.stringify(update)}, error: ${JSON.stringify(e)}`);
return;
}
}
$('#client-object').val(JSON.stringify(jsonObject, null, 2));
}
let processBacklog = () => {
patchBacklog.reverse().forEach((update) => {
applyUpdate(update);
});
readyForPatches = true
}
/* Constructs the JSON object from history by finding the complete object,
applying the patches published subsequently in history, and then applies
any patches received on the channel during this construction phase */
let constructObjectFromHistory = () => {
patchBacklog.splice(0);
readyForPatches = false;
subscriberChannel.history({ untilAttach: true, limit: 1 }, (err, resultPage) => {
if (err) {
log('client', `Fatal error, could not get first update in history ${JSON.stringify(err)}`);
return;
}
if (resultPage.items.length === 0) {
log('client', `No history exists, waiting for updates`);
processBacklog();
return;
}
let recentUpdate = resultPage.items[0].data;
if (recentUpdate.object) {
applyUpdate(recentUpdate);
log('client', `First untilAttach query return complete object with serial ${recentUpdate.serial}`);
processBacklog();
} else {
/* Determine how far back we need to go in the history to obtain the complete object and all
subsequent updates that need to be applie up until the point this channel has become attached */
let updatesToRetrieve = recentUpdate.serial - recentUpdate.root + 1;
log('client', `Retrieving backlog from history of ${updatesToRetrieve} updates`);
subscriberChannel.history({ untilAttach: true, limit: updatesToRetrieve }, (err, resultPage) => {
if (err) {
log('client', `Fatal error, could not get ${updatesToRetrieve} updates from history ${JSON.stringify(err)}`);
return;
}
resultPage.items.reverse().forEach((msg) => {
applyUpdate(msg.data);
processBacklog();
});
});
}
});
}
/* Listen for updates over the Ably channel in real time */
subscriberChannel.subscribe((msg) => {
if (!readyForPatches) {
/* If patches from history have not yet been applied then put the
updates into a backlog to be applied once the history updates have been
applied and the JSON object has been constructed */
patchBacklog.push(msg.data);
} else {
applyUpdate(msg.data);
}
});
subscriberChannel.attach();
subscriberChannel.on('failed', (stateChange) => log('client', `Fatal error, could not attach to channel ${JSON.stringify(stateChange.reason)}`));
subscriberChannel.on('suspended', (stateChange) => log('client', `Update channel suspended, no updates will be received. Is there a valid Internet connection?`));
subscriberChannel.on('attached', (stateChange) => {
log('client', `Channel attached, constructing object from history`);
constructObjectFromHistory();
});
subscriberChannel.on('update', (stateChange) => {
if (!stateChange.resumed) {
log('client', `Continuity on update channel lost, reconstructing object from history`);
constructObjectFromHistory();
}
});
});
/* --- FABRICATE SCORE BOARD DATA --- */
/*
Function to fabricate fake score board JSON data in scoreBoard object.
In a realistic implementation you would have an external source generating this data.
*/
function randomlyChangeScoreBoard(forcePlayers) {
let addPlayer = () => {
let availableNames = Names.filter((name) => { return !(name in scoreBoard); });
let name = availableNames[Math.floor(Math.random() * availableNames.length)];
scoreBoard[name] = {
wins: Math.round(Math.random() * Defaults.score),
losses: Math.round(Math.random() * Defaults.score)
};
}
if (forcePlayers) {
for (var i = Object.keys(scoreBoard).length; i < forcePlayers; i++) {
addPlayer();
}
while (Object.keys(scoreBoard).length > forcePlayers) {
delete scoreBoard[Object.keys(scoreBoard)[0]];
}
} else {
/* Add a new person into the scoreboard if space 20% of the time */
if ((Object.keys(scoreBoard).length < Defaults.boardLength) && (Math.random() < 0.20)) {
addPlayer();
}
}
/* Delete someone from the scoreboard 10% of the time */
if ((Object.keys(scoreBoard).length > 2) && (Math.random() < 0.1)) {
let names = Object.keys(scoreBoard);
delete scoreBoard[names[Math.floor(Math.random() * names.length)]];
}
/*
Modify scores by up to 1/-1 for each user's score 25% of the time
Vary quantity of changes for each update though to simulate large
and simulate updates
*/
let names = arrayShuffle(Object.keys(scoreBoard));
names = names.slice(0, Math.ceil(names.length * Math.random()));
names.forEach((name) => {
if (scoreBoard.hasOwnProperty(name)) {
let existing = scoreBoard[name];
if (Math.random() < 0.25) {
existing.wins = Math.round(existing.wins + (Math.random() > 0 ? 1 : -1));
}
if (Math.random() < 0.25) {
existing.losses = Math.round(existing.losses + (Math.random() > 0 ? 1 : -1));
}
}
});
}
/* --- UTILITY METHODS --- */
function log(type, message) {
let $textarea = $('#' + type + '-log');
let text = getFormattedTime(new Date()) + " - " + message + "\n" + $textarea.val();
$textarea.val(text.split("\n").slice(0,100).join("\n"));
}
function getFormattedTime(date) {
let hours = date.getHours();
let minutes = date.getMinutes();
let seconds = date.getSeconds();
hours = paddingLeft(hours, "00");
minutes = paddingLeft(minutes, "00");
seconds = paddingLeft(seconds, "00");
return `${hours}:${minutes}:${seconds}`;
};
function paddingLeft(str, paddingValue) {
return String(paddingValue + str).slice(-paddingValue.length);
};
function arrayShuffle(o) {
for(var j, x, i = o.length; i; j = parseInt(Math.random() * i), x = o[--i], o[i] = o[j], o[j] = x);
return o;
}
function fileSizeSI(size) {
var e = (Math.log(size) / Math.log(1e3)) | 0;
return +(size / Math.pow(1e3, e)).toFixed(2) + ' ' + ('kMGTPEZY'[e - 1] || '') + 'B';
}
/* ---- UI CODE ---- */
/* Listen for click events on the start publish / stop publish buttons */
$('#publisher-btn').on('click', () => {
if (isPublishing()) {
stopPublishing();
} else {
startPublishing();
}
});
/* Listen for changes in max object size drop down and adjust scoreboard size accordingly */
$(() => {
let $boardSize = $("#board-size");
for (var i = 2; i <= Names.length; i++) {
$boardSize.append(`<option value="${i}" ${i === Defaults.boardLength ? 'selected' : ''}>${i}</option>`);
}
$boardSize.on('change', () => {
Defaults.boardLength = Number($boardSize.val());
randomlyChangeScoreBoard(Defaults.boardLength);
});
});
</script>
</html>