alchemymvc 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ const QUEUE = [];
2
+ let queue_check_id,
3
+ _total_postponement_counter = 0;
4
+
5
+ /**
6
+ * The Postponement Class represents requests that will be handled later.
7
+ *
8
+ * @author Jelle De Loecker <jelle@elevenways.be>
9
+ * @since 1.3.1
10
+ * @version 1.3.2
11
+ *
12
+ * @param {Conduit} conduit The original conduit
13
+ */
14
+ const Postponement = Function.inherits('Alchemy.Base', 'Alchemy.Conduit', function Postponement(conduit, id, options) {
15
+
16
+ // The original conduit instance
17
+ this.original_conduit = conduit;
18
+
19
+ // The original response object of the conduit
20
+ this.original_response = conduit.response;
21
+
22
+ // Get the session
23
+ this.session = conduit.getSession();
24
+
25
+ // The identifier of this postponement
26
+ this.id = id;
27
+
28
+ // The original path string
29
+ this.original_path = conduit.path;
30
+
31
+ // The URL where to get postponement info
32
+ this.url = '/alchemy/postponed/' + id;
33
+
34
+ // The last known position in the queue
35
+ this.last_queue_position = null;
36
+
37
+ // Postponement options
38
+ this.options = options || {};
39
+
40
+ // When did this postponement start
41
+ this.started = Date.now();
42
+
43
+ // When did this postponement end?
44
+ this.ended = null;
45
+
46
+ // Has this postponement expired?
47
+ this.expired = false;
48
+
49
+ // Has this postponement been released yet?
50
+ this.released = false;
51
+
52
+ // When was the last check made from the client?
53
+ this.last_check = this.started;
54
+
55
+ // Also attach this postponement to the conduit
56
+ conduit.postponement = this;
57
+
58
+ // Keep track of the total amount of postponements ever
59
+ _total_postponement_counter++;
60
+ });
61
+
62
+ /**
63
+ * The queued postponements
64
+ *
65
+ * @author Jelle De Loecker <jelle@elevenways.be>
66
+ * @since 1.3.1
67
+ * @version 1.3.1
68
+ *
69
+ * @return {Postponement[]}
70
+ */
71
+ Postponement.setStatic('queue', QUEUE);
72
+
73
+ /**
74
+ * Get the total amount of postponements ever
75
+ *
76
+ * @author Jelle De Loecker <jelle@elevenways.be>
77
+ * @since 1.3.1
78
+ * @version 1.3.1
79
+ *
80
+ * @return {Number}
81
+ */
82
+ Postponement.setStaticProperty(function total_postponement_counter() {
83
+ return _total_postponement_counter;
84
+ });
85
+
86
+ /**
87
+ * The current queue length
88
+ *
89
+ * @author Jelle De Loecker <jelle@elevenways.be>
90
+ * @since 1.3.1
91
+ * @version 1.3.1
92
+ *
93
+ * @return {Number}
94
+ */
95
+ Postponement.queue_length = 0;
96
+
97
+ /**
98
+ * How long has this been waiting?
99
+ *
100
+ * @author Jelle De Loecker <jelle@elevenways.be>
101
+ * @since 1.3.1
102
+ * @version 1.3.1
103
+ *
104
+ * @return {Number}
105
+ */
106
+ Postponement.setProperty(function time_waited() {
107
+
108
+ if (!this.ended) {
109
+ return Date.now() - this.started;
110
+ }
111
+
112
+ return this.ended - this.started;
113
+ });
114
+
115
+ /**
116
+ * How long has this postponement been left unchecked?
117
+ *
118
+ * @author Jelle De Loecker <jelle@elevenways.be>
119
+ * @since 1.3.1
120
+ * @version 1.3.1
121
+ *
122
+ * @return {Number}
123
+ */
124
+ Postponement.setProperty(function time_unchecked() {
125
+ return Date.now() - this.last_check;
126
+ });
127
+
128
+ /**
129
+ * Has this postponement been abandoned?
130
+ *
131
+ * @author Jelle De Loecker <jelle@elevenways.be>
132
+ * @since 1.3.1
133
+ * @version 1.3.2
134
+ *
135
+ * @return {Number}
136
+ */
137
+ Postponement.setProperty(function has_been_abandoned() {
138
+
139
+ // Postponements that haven't been checked in 3 minutes
140
+ // are considered abandoned
141
+ if (this.time_unchecked > 180_000) {
142
+ return true;
143
+ }
144
+
145
+ return false;
146
+ });
147
+
148
+ /**
149
+ * Get the current position in the queue
150
+ *
151
+ * @author Jelle De Loecker <jelle@elevenways.be>
152
+ * @since 1.3.1
153
+ * @version 1.3.1
154
+ *
155
+ * @return {Number}
156
+ */
157
+ Postponement.setProperty(function position_in_queue() {
158
+
159
+ if (this.last_queue_position == null) {
160
+ return null;
161
+ }
162
+
163
+ let index = QUEUE.indexOf(this);
164
+
165
+ if (index == -1) {
166
+ index = null;
167
+ }
168
+
169
+ this.last_queue_position = index;
170
+
171
+ return index;
172
+ });
173
+
174
+ /**
175
+ * Schedule a check of the queue
176
+ *
177
+ * @author Jelle De Loecker <jelle@elevenways.be>
178
+ * @since 1.3.1
179
+ * @version 1.3.1
180
+ *
181
+ * @param {Conduit} conduit The new conduit
182
+ */
183
+ Postponement.setStatic(function scheduleQueueCheck() {
184
+
185
+ if (queue_check_id) {
186
+ return;
187
+ }
188
+
189
+ queue_check_id = setTimeout(() => {
190
+ queue_check_id = null;
191
+ Postponement.checkQueue();
192
+ }, 5000);
193
+ });
194
+
195
+ /**
196
+ * Check the (top of the) queue
197
+ *
198
+ * @author Jelle De Loecker <jelle@elevenways.be>
199
+ * @since 1.3.1
200
+ * @version 1.3.1
201
+ *
202
+ * @param {Conduit} conduit The new conduit
203
+ */
204
+ Postponement.setStatic(function checkQueue() {
205
+
206
+ let length = QUEUE.length;
207
+
208
+ this.queue_length = length;
209
+
210
+ if (!length) {
211
+ return;
212
+ }
213
+
214
+ let to_remove = [],
215
+ postponement,
216
+ max = length,
217
+ i;
218
+
219
+ if (max > 20) {
220
+ max = 20;
221
+ }
222
+
223
+ for (i = 0; i < max; i++) {
224
+ postponement = QUEUE[i];
225
+
226
+ if (postponement.has_been_abandoned) {
227
+ to_remove.push(postponement);
228
+ }
229
+ }
230
+
231
+ for (i = 0; i < to_remove.length; i++) {
232
+ postponement = to_remove[i];
233
+ postponement.expire();
234
+ }
235
+
236
+ Postponement.scheduleQueueCheck();
237
+ });
238
+
239
+ /**
240
+ * Handle a request
241
+ *
242
+ * @author Jelle De Loecker <jelle@elevenways.be>
243
+ * @since 1.3.1
244
+ * @version 1.3.2
245
+ *
246
+ * @param {Conduit} conduit The new conduit
247
+ */
248
+ Postponement.setMethod(function handleRequest(conduit) {
249
+
250
+ if (conduit.ajax) {
251
+ let check_queue = conduit.param('check_queue');
252
+
253
+ if (check_queue) {
254
+
255
+ let data = {
256
+ position : this.position_in_queue,
257
+ allowed : false,
258
+ location : null,
259
+ };
260
+
261
+ if (this.released) {
262
+ data.allowed = true;
263
+ data.location = this.url;
264
+ } else if (this.expired) {
265
+ // If the request has expired, send them to the original url again
266
+ data.allowed = true;
267
+ data.location = this.original_path;
268
+ } else if (this.attemptUnlock()) {
269
+ data.allowed = true;
270
+ data.location = this.url;
271
+ } else if (data.position == null) {
272
+ // Something else has gone wrong?
273
+ // Send them to the url anyway
274
+ data.allowed = true;
275
+ data.location = this.url;
276
+ }
277
+
278
+ conduit.end(data);
279
+
280
+ return;
281
+ }
282
+ }
283
+
284
+ let resumed = this.attemptResume(conduit);
285
+
286
+ if (!resumed) {
287
+ this.showPostponementMessage(conduit);
288
+ }
289
+ });
290
+
291
+ /**
292
+ * Attempt to unlock
293
+ *
294
+ * @author Jelle De Loecker <jelle@elevenways.be>
295
+ * @since 1.3.1
296
+ * @version 1.3.2
297
+ *
298
+ * @return {Boolean} True if the request is being resumed
299
+ */
300
+ Postponement.setMethod(function attemptUnlock() {
301
+
302
+ if (this.released) {
303
+ return true;
304
+ }
305
+
306
+ this.last_check = Date.now();
307
+
308
+ Postponement.scheduleQueueCheck();
309
+
310
+ if (this.position_in_queue > 5) {
311
+ return false;
312
+ }
313
+
314
+ if (alchemy.lagInMs() > 100) {
315
+ return false;
316
+ }
317
+
318
+ this.released = true;
319
+
320
+ return this.released;
321
+ });
322
+
323
+ /**
324
+ * Attempt to resume this postponement.
325
+ * If it's in a queue and it's not our turn yet, do nothing.
326
+ *
327
+ * @author Jelle De Loecker <jelle@elevenways.be>
328
+ * @since 1.3.1
329
+ * @version 1.3.1
330
+ *
331
+ * @param {Conduit} conduit The new conduit
332
+ *
333
+ * @return {Boolean} True if the request is being resumed
334
+ */
335
+ Postponement.setMethod(function attemptResume(conduit) {
336
+
337
+ if (!this.attemptUnlock()) {
338
+ return false;
339
+ }
340
+
341
+ // Let the conduit know the response is being requested now
342
+ // (Certain postponements also delay the processing of the request)
343
+ this.original_conduit.emit('get-postponed-response');
344
+
345
+ // Once we're sure the postponed end has been reached,
346
+ // actually send that to the browser
347
+ this.original_conduit.afterOnce('after-postponed-end', () => {
348
+
349
+ this.original_conduit.response = conduit.response;
350
+ this.original_conduit._end(...this.original_conduit._end_arguments);
351
+
352
+ this.remove();
353
+ });
354
+
355
+ return true;
356
+ });
357
+
358
+ /**
359
+ * Show the postponement message to the given conduit
360
+ *
361
+ * @author Jelle De Loecker <jelle@elevenways.be>
362
+ * @since 1.3.1
363
+ * @version 1.3.1
364
+ *
365
+ * @param {Conduit} conduit
366
+ */
367
+ Postponement.setMethod(function showPostponementMessage(conduit) {
368
+
369
+ if (!conduit) {
370
+ conduit = this.original_conduit;
371
+ }
372
+
373
+ let response = conduit?.response || this.original_response;
374
+
375
+ if (!response) {
376
+ throw new Error('Failed to find a response instance, unable to show postponement message');
377
+ }
378
+
379
+ response.setHeader('X-Robots-Tag', 'none');
380
+
381
+ let position_in_queue = this.position_in_queue;
382
+
383
+ // Already set the cookies
384
+ if (conduit.new_cookie_header.length) {
385
+ response.setHeader('set-cookie', conduit.new_cookie_header);
386
+ }
387
+
388
+ // Set the location header where the client should look at later
389
+ response.setHeader('Location', this.url);
390
+ response.setHeader('Content-Type', 'text/html');
391
+
392
+ if (this.options.expected_duration) {
393
+ response.setHeader('Expected-Duration', Number(this.options.expected_duration / 1000).toFixed(2));
394
+ }
395
+
396
+ // Write the headers & status
397
+ response.writeHead(this.options.status || 202);
398
+
399
+ let end_message = this.options.end_message;
400
+
401
+ // End the response if wanted
402
+ if (end_message !== false) {
403
+ if (!end_message) {
404
+ if (position_in_queue != null) {
405
+ end_message = this.getQueueHTML();
406
+ } else {
407
+ end_message = 'The response has been postponed, you can find it at <a href="' + this.url + '">' + this.url + '</a>';
408
+ }
409
+ }
410
+ } else {
411
+ end_message = '';
412
+ }
413
+
414
+ response.end(end_message);
415
+ });
416
+
417
+ /**
418
+ * Remove this postponement
419
+ *
420
+ * @author Jelle De Loecker <jelle@elevenways.be>
421
+ * @since 1.3.1
422
+ * @version 1.3.1
423
+ *
424
+ * @param {Boolean} expired True if this is due to an expired session
425
+ */
426
+ Postponement.setMethod(function remove(expired) {
427
+
428
+ if (!expired) {
429
+ const session = this.session;
430
+
431
+ this.ended = Date.now();
432
+ session.postponements.remove(this.id);
433
+
434
+ session.addFinishedQueueDuration(this.time_waited);
435
+ }
436
+
437
+ let index = this.position_in_queue;
438
+
439
+ if (index != null) {
440
+ QUEUE.splice(index, 1);
441
+ }
442
+ });
443
+
444
+ /**
445
+ * Called when the session or the postponement expires
446
+ *
447
+ * @author Jelle De Loecker <jelle@elevenways.be>
448
+ * @since 1.3.1
449
+ * @version 1.3.1
450
+ */
451
+ Postponement.setMethod(function expire() {
452
+ this.remove(true);
453
+ this.expired = true;
454
+ });
455
+
456
+ /**
457
+ * Put this postponement in a queue
458
+ *
459
+ * @author Jelle De Loecker <jelle@elevenways.be>
460
+ * @since 1.3.1
461
+ * @version 1.3.1
462
+ */
463
+ Postponement.setMethod(function putInQueue() {
464
+
465
+ if (this.last_queue_position != null) {
466
+ return;
467
+ }
468
+
469
+ let new_length = QUEUE.push(this) - 1;
470
+
471
+ this.last_queue_position = new_length - 1;
472
+
473
+ // Update the queue length
474
+ Postponement.queue_length = new_length;
475
+
476
+ return this.last_queue_position;
477
+ });
478
+
479
+ /**
480
+ * Get the HTML message for in the queue
481
+ *
482
+ * @author Jelle De Loecker <jelle@elevenways.be>
483
+ * @since 1.3.1
484
+ * @version 1.3.1
485
+ */
486
+ Postponement.setMethod(function getQueueHTML() {
487
+
488
+ let position = this.last_queue_position + 1;
489
+
490
+ let html = `<!DOCTYPE html>
491
+ <html>
492
+ <head>
493
+ <title>Please Wait...</title>
494
+ <style>
495
+ body {
496
+ background-color: #F5F5F5;
497
+ display: flex;
498
+ justify-content: center;
499
+ align-items: center;
500
+ height: 100vh;
501
+ font-family: 'Open Sans', sans-serif;
502
+ }
503
+ .main-logo {
504
+ max-width: 50vw;
505
+ max-height: 50vw;
506
+ object-fit: contain;
507
+ min-width: 150px;
508
+ max-width: 150px;
509
+ }
510
+ .container {
511
+ text-align: center;
512
+ background: #F0F8FF;
513
+ padding: 20px;
514
+ border-radius: 10px;
515
+ box-shadow: 5px 5px 10px #B0C4DE;
516
+ }
517
+ h1 {
518
+ font-size: 3em;
519
+ margin-bottom: 20px;
520
+ }
521
+ p {
522
+ font-size: 1.5em;
523
+ margin-bottom: 20px;
524
+ }
525
+ #queue {
526
+ font-size: 1.2em;
527
+ margin-bottom: 20px;
528
+ }
529
+ .loading {
530
+ display: flex;
531
+ justify-content: center;
532
+ align-items: center;
533
+ margin: 0 auto;
534
+ border: 6px solid #F5F5F5;
535
+ border-top: 6px solid #3498DB;
536
+ border-radius: 50%;
537
+ width: 60px;
538
+ height: 60px;
539
+ animation: spin 2s linear infinite;
540
+ }
541
+ @keyframes spin {
542
+ 0% { transform: rotate(0deg); }
543
+ 100% { transform: rotate(360deg); }
544
+ }
545
+ </style>
546
+ </head>
547
+ <body>
548
+ <div class="container">
549
+ `
550
+
551
+ if (alchemy.settings.main_logo) {
552
+ html += `<img src="${alchemy.settings.main_logo}" class="main-logo">\n`;
553
+ }
554
+
555
+ html += `
556
+ <h1>Server is busy</h1>
557
+ <p id="queue">You are #${ position } in the queue.</p>
558
+ <div class="loading">
559
+ <div class="spinner"></div>
560
+ </div>
561
+ </div>
562
+ <script>
563
+ function checkQueue() {
564
+ let xhr = new XMLHttpRequest();
565
+ xhr.open('GET', '${ this.url }?check_queue=1', true);
566
+ xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
567
+ xhr.onreadystatechange = function() {
568
+ if (xhr.readyState === 4) {
569
+ let data = JSON.parse(xhr.responseText);
570
+ let element = document.getElementById("queue");
571
+
572
+ if (data.allowed && data.location) {
573
+ element.innerHTML = "Redirecting!";
574
+ window.location.href = data.location;
575
+ return;
576
+ }
577
+
578
+ let queue = data.position;
579
+ element.innerHTML = "You are #" + queue + " in the queue";
580
+
581
+ setTimeout(checkQueue, 15000);
582
+ }
583
+ };
584
+ xhr.send();
585
+ };
586
+
587
+ setTimeout(checkQueue, 5000);
588
+ </script>
589
+ </body>
590
+ </html>`;
591
+
592
+ return html;
593
+ });
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Route Class
3
3
  *
4
- * @author Jelle De Loecker <jelle@develry.be>
4
+ * @author Jelle De Loecker <jelle@elevenways.be>
5
5
  * @since 0.2.0
6
- * @version 1.3.0
6
+ * @version 1.3.1
7
7
  */
8
8
  const Route = Function.inherits('Alchemy.Base', function Route(router, paths, options) {
9
9
 
@@ -55,6 +55,9 @@ const Route = Function.inherits('Alchemy.Base', function Route(router, paths, op
55
55
  this.controller = null;
56
56
  this.action = null;
57
57
 
58
+ // All routes can be postponed by default
59
+ this.can_be_postponed = true;
60
+
58
61
  this.setPaths(paths);
59
62
  });
60
63
 
@@ -182,6 +185,19 @@ Route.setMethod(function setPermission(permission) {
182
185
  }
183
186
  });
184
187
 
188
+ /**
189
+ * Set if this route can be postponed
190
+ *
191
+ * @author Jelle De Loecker <jelle@elevenways.be>
192
+ * @since 1.3.1
193
+ * @version 1.3.1
194
+ *
195
+ * @param {Boolean} postponable
196
+ */
197
+ Route.setMethod(function setCanBePostponed(postponable) {
198
+ this.can_be_postponed = postponable;
199
+ });
200
+
185
201
  /**
186
202
  * Compile paths for this route
187
203
  *
@@ -765,9 +765,9 @@ RouterClass.setMethod(function setOption(name, value) {
765
765
  /**
766
766
  * Add a route
767
767
  *
768
- * @author Jelle De Loecker <jelle@develry.be>
768
+ * @author Jelle De Loecker <jelle@elevenways.be>
769
769
  * @since 0.2.0
770
- * @version 1.3.0
770
+ * @version 1.3.1
771
771
  *
772
772
  * @param {Object} args
773
773
  * @param {String} args.name Optional route name
@@ -828,6 +828,10 @@ RouterClass.setMethod(function add(args) {
828
828
  route.setPermission(args.permission);
829
829
  }
830
830
 
831
+ if (args.can_be_postponed != null) {
832
+ route.setCanBePostponed(args.can_be_postponed);
833
+ }
834
+
831
835
  if (args.breadcrumb) {
832
836
  breadcrumb_options = {
833
837
  route : route
@@ -514,4 +514,40 @@ Schema.setMethod(function hasMany(alias, modelName, options) {
514
514
  */
515
515
  Schema.setMethod(function hasOneChild(alias, modelName, options) {
516
516
  this.addAssociation('HasOneChild', alias, modelName, options);
517
+ });
518
+
519
+ /**
520
+ * Clone
521
+ *
522
+ * @author Jelle De Loecker <jelle@elevenways.be>
523
+ * @since 1.3.1
524
+ * @version 1.3.1
525
+ *
526
+ * @return {Object}
527
+ */
528
+ Schema.setMethod(function dryClone(wm, custom_method) {
529
+
530
+ if (!custom_method) {
531
+ custom_method = 'toShallowClone';
532
+ }
533
+
534
+ let cloned = JSON.clone(this.toDry(), custom_method, wm);
535
+
536
+ cloned = this.constructor.unDry(cloned.value);
537
+
538
+ return cloned;
539
+ });
540
+
541
+ /**
542
+ * Clone using JSON-Dry
543
+ * (Needed anyway because Deck also has a clone method)
544
+ *
545
+ * @author Jelle De Loecker <jelle@elevenways.be>
546
+ * @since 1.3.1
547
+ * @version 1.3.1
548
+ *
549
+ * @return {Object}
550
+ */
551
+ Schema.setMethod(function clone() {
552
+ return this.dryClone(new WeakMap(), 'toShallowClone');
517
553
  });