/
index.ts
480 lines (436 loc) · 15.4 KB
/
index.ts
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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
export interface TimeoutOptions {
/**
* Number of milliseconds from creation time to run the Timeout.
*/
timeMillis: number
/**
* Whether to indefinitely reschedule on the same interval once
* the Timeout expires. This can be cancelled with {@link Timeout#cancel}
* or overridden with adding another Timeout of the same key (and
* bucket, of course) and setting {@link overwriteKey} to true.
*/
recurring?: boolean
/**
* Unique, identifying, key for the Timeout. Used to avoid creating
* multiple timers of the same key and also provides debounce type
* functionality.
*
* No key means that the callback is treated independently, even if
* it has strict equality with another another callback that has already
* been scheduled.
*/
key?: string
/**
* Adds the key to a bucket so that all keys in the bucket can be
* treated together.
*
* Defaults to {@link Scheduler#DEFAULT_BUCKET_KEY}.
*/
bucketKey?: string
/**
* Whether or not to overwrite the Timeout if the key already exists.
*
* This also acts as a debouncer. If overwriteKey = true this will
* cause the timeout to debounce until no other calls have been made
* to add a Timeout with the same key before it has been called. If
* overwriteKey = false then only the first callback for the key will
* be called within the interval.
*
* Defaults to false.
*/
overwriteKey?: boolean
/**
* When paused, the time remaining can either be saved and started back
* when the Timeout is resumed or it can use the Timeout's original expiration.
* When this is true, the original expiration will be used; when this is false
* the expiration will be adjusted by the pause time.
*
* E.g., Timeout has 250ms before expiration and is paused; resume is called
* after 500ms; if ignorePauseDelay = true then the Timeout's callback would
* be run immediately, however if ignorePauseDelay = false then the Timeout
* will run 250ms after resume is called.
*
* Note this can be overridden by passing in ignorePauseDelay in the pause or
* resume of the Timeout or Scheduler.
*
* Defaults to false (i.e., adjust expiration by pause delay).
*/
ignorePauseDelay?: boolean
}
/**
* Return value from creating a Timeout. Allows for checking the state
* of the Timeout and pausing, resuming, or canceling the Timeout. The
* options used to create the Timeout are also provided here along with
* any defaults that were set for them.
*/
export interface Timeout {
/**
* Pretty self-explanatory. Note that the state will never be 'complete'
* if the Timeout is recurring.
*/
readonly state: "pending" | "paused" | "complete"
/**
* Whether or not the Timeout has completed running. This will always be
* false for recurring Timeouts. (This is just state === "complete".)
*/
readonly isComplete: boolean
/**
* Allows for pausing the Timeout for any amount of time. When resumed
* one of two things will happen:
* 1. TimeoutOptions.ignorePauseDelay = false: the time remaining on the
* callback will be used to generate the new expiration time from
* that moment forward.
* 2. TimeoutOptions.ignorePauseDelay = true: the expiration time will not
* change when resumed and will expire (callback called) whenever it
* originally would have expired.
*
* This allows for providing ignorePauseDelay on a per-call basis, which will
* override the existing TimeoutOptions.ignorePauseDelay if provided. Leave
* this as undefined if it is not desired to override TimeoutOptions.ignorePauseDelay.
*/
readonly pause: (ignorePauseDelay?: boolean) => void
/**
* Starts the Timeout back if it was paused, otherwise does nothing.
* See {@link pause} for more information.
*
* This allows for providing ignorePauseDelay on a per-call basis, which will
* override the existing TimeoutOptions.ignorePauseDelay if provided. Leave
* this as undefined if it is not desired to override TimeoutOptions.ignorePauseDelay.
*/
readonly resume: (ignorePauseDelay?: boolean) => void
/**
* Cancels the Timeout from running if it has not already run or is recurring.
*/
readonly cancel: () => void
/**
* How long until the Timeout is called.
*/
readonly remainingTime: number
/**
* The bucketKey and key will have been set to defaults on this if they were
* not originally provided.
*/
readonly options: Readonly<TimeoutOptions>
}
/**
* Used internally (Scheduler) for tracking.
*/
interface TimeoutInternal extends Timeout {
/**
* Internally is not read only.
*/
state: "pending" | "paused" | "complete"
/**
* Date.now() + timeMillis. If the Timeout is paused, this will be set
* to the remaining time (expiration - Date.now()) while it is paused.
* Once the timeout is resumed this value will be used to reset the
* expiration time (expiration + Date.now()).
*/
expiration: number
/**
* The callback to be made when the Timeout expires.
*/
callback: () => void
}
/**
* The Scheduler must be provided an interval to check for events. This interval
* can be as fine as 1 (or even 0...) millisecond. This allows the user to create
* multiple Schedulers with tighter or looser intervals. By default the Scheduler
* will be started when it is created, but this can be overridden by providing
* false as the second constructor argument.
*
* This is not exported as a default as it is a bad practice IM(and others)O.
*/
export class Scheduler {
static DEFAULT_BUCKET_KEY = "__DEFAULT__BUCKET__KEY__";
private idCounter: number = 0
// the interval on which the scheduler checks for expired tasks
private readonly interval: number
private expectedSchedulerIntervalExpiration: number // Date.now() + interval;
private remainingPauseTime: number | undefined // expectedExpiration - Date.now() (date.now of pause time)
/**
* Used to track the timeouts for each bucket. Timeouts that do not
* specify a bucket are stored here with DEFAULT_BUCKET_KEY.
*/
private buckets: {[bucketKey: string]: {[timeoutKey: string]: TimeoutInternal}} = {
[Scheduler.DEFAULT_BUCKET_KEY]: {}
};
private shutdown = false;
private timeoutId: number | NodeJS.Timeout
constructor(intervalMillis: number, start = true) {
if (typeof intervalMillis !== "number") {
throw new Error("Interval must be a number.")
}
this.interval = intervalMillis;
if (start) {
this.start();
}
}
private started = false;
/**
* Starts the scheduler if it has not already been started. Can
* be used to start again after it has been stopped.
*/
start = () => {
if (!this.started) { // ensures multiple
this.shutdown = false;
this.started = true;
this.__INTERNAL__run(this.interval);
}
}
/**
* Stops the scheduler.
*/
stop = () => {
// need to keep track of this so that
if (!this.shutdown) {
this.shutdown = true;
this.started = false;
clearTimeout(this.timeoutId as any);
// clear all Timeouts
this.buckets = {};
}
};
private __INTERNAL__runExpiredCallbacks = () => {
for (const bucketsKey in this.buckets) {
const bucket = this.buckets[bucketsKey];
for (const bucketKey in bucket) {
const timeout = bucket[bucketKey];
if (timeout.state === "pending" && timeout.remainingTime <= 0) { // will call
// run it and reset the expiration or remove it
timeout.callback();
if (timeout.options.recurring) {
timeout.expiration = Date.now() + timeout.options.timeMillis;
} else {
timeout.state = "complete";
// delete it
timeout.cancel();
}
}
}
}
this.__INTERNAL__run(this.interval);
}
private __INTERNAL__run = (timeout: number) => {
if (!this.shutdown) { // need to check if it has been shutdown before rerunning
this.expectedSchedulerIntervalExpiration = Date.now() + timeout;
this.timeoutId = setTimeout(this.__INTERNAL__runExpiredCallbacks, timeout);
}
}
/**
* Pauses all jobs and the scheduler itself.
*
* Optionally supply ignorePauseDelay to override each Timeout's
* ignorePauseDelay setting.
*/
pause = (ignorePauseDelay?: boolean) => {
if (this.remainingPauseTime === undefined) {
this.remainingPauseTime = this.expectedSchedulerIntervalExpiration - Date.now();
clearTimeout(this.timeoutId as any);
for (const bucketsKey in this.buckets) {
const bucket = this.buckets[bucketsKey];
if (bucket) {
for (const key in bucket) {
bucket[key].pause(ignorePauseDelay);
}
}
}
}
}
/**
* Resumes the scheduler and all paused timeouts. This does not resume
* all paused timeouts if the scheduler itself is not paused. If this
* is desired, use {@link getTimeoutsForBucket} and loop through them.
*
* Optionally supply ignorePauseDelay to override each Timeout's
* ignorePauseDelay setting.
*/
resume = (ignorePauseDelay?: boolean) => {
if (this.remainingPauseTime !== undefined) {
// need to restart timeout with lower interval
this.__INTERNAL__run(this.remainingPauseTime);
this.remainingPauseTime = undefined;
for (const bucketsKey in this.buckets) {
const bucket = this.buckets[bucketsKey];
if (bucket) {
for (const key in bucket) {
bucket[key].resume();
}
}
}
}
}
/**
* Adds the callback to be run with the provided options. Defaults will be
* set if they are not supplied. options must be a number that will correspond
* to a time (in milliseconds) or options must be an object containing 'timeMillis'.
* The created Timeout is returned so that it can be controlled as desired.
*/
add = (callback: () => void, options: number | TimeoutOptions): Timeout => {
if (
options === undefined || options === null ||
(typeof options !== "number" && options.timeMillis == undefined)
) {
throw new Error("Cannot add callback without timeout.");
}
if (typeof options === "number") {
options = {
timeMillis: options
}
}
options.key = options.key || (this.idCounter++).toString();
options.bucketKey = options.bucketKey || Scheduler.DEFAULT_BUCKET_KEY;
const expiration = options.timeMillis + Date.now();
/*
* Capturing the scheduler's buckets so that it can be used in the cancel
* function. It seems better to capture the bucket rather than the key and
* bucketKey (and obtain them via options), because the options are more
* easily accessed by the user and for whatever reason they could change
* them randomly (unlikely, but who knows..). Anyway, one or the other needs
* to be captured.
* */
const schedulerBuckets = this.buckets;
const timeout: TimeoutInternal = {
options,
expiration,
state: "pending",
get isComplete() {
return this.state === "complete"
},
cancel() {
// remove it from bucket's keys
if (schedulerBuckets[this.options.bucketKey]) {
delete schedulerBuckets[this.options.bucketKey][this.options.key];
}
// if (this.buckets[bucketKey]) {
// delete this.buckets[bucketKey][key];
// }
},
pause(ignorePauseDelay?: boolean) {
if (this.state === 'pending') {
this.state = "paused"
if (
(ignorePauseDelay !== undefined && !ignorePauseDelay) ||
(ignorePauseDelay === undefined && !this.options.ignorePauseDelay)
) {
this.expiration = this.expiration - Date.now();
}
}
},
resume(ignorePauseDelay?: boolean) {
if (this.state === 'paused') {
this.state = 'pending';
if (
(ignorePauseDelay !== undefined && !ignorePauseDelay) ||
(ignorePauseDelay === undefined && !this.options.ignorePauseDelay)
) {
this.expiration = this.expiration + Date.now();
}
}
},
get remainingTime() {
return this.expiration - Date.now();
},
callback
};
let bucket = schedulerBuckets[options.bucketKey];
if (!bucket) {
bucket = {};
schedulerBuckets[options.bucketKey] = bucket;
}
const existingTimeoutForKey = bucket[options.key];
if (existingTimeoutForKey && !options.overwriteKey) {
// do not overwrite the existing timeout, return it instead.
return existingTimeoutForKey;
}
// does not already exists or should overwrite
bucket[options.key] = timeout;
return timeout;
}
/**
* Pauses the Timeout if it exists. Returns the Timeout that was paused or
* undefined if the Timeout did not exist.
*/
pauseTimeout = (timeoutKey: string,
bucketKey: string = Scheduler.DEFAULT_BUCKET_KEY,
ignorePauseDelay?: boolean): Timeout | undefined => {
const bucket = this.buckets[bucketKey];
if (bucket) {
const timeout = bucket[timeoutKey];
if (timeout) {
timeout.pause(ignorePauseDelay);
}
return timeout;
}
return;
}
/**
* Pauses the Timeout if it exists. Returns the Timeout that was paused or
* undefined if the Timeout did not exist.
*/
pauseTimeoutOnDefaultBucket = (timeoutKey: string, ignorePauseDelay?: boolean): Timeout | undefined => {
return this.pauseTimeout(timeoutKey, Scheduler.DEFAULT_BUCKET_KEY, ignorePauseDelay);
}
/**
* Resumes the Timeout if it exists. Returns the Timeout that was resumed or
* undefined if the Timeout did not exist.
*/
resumeTimeout = (timeoutKey: string,
bucketKey: string = Scheduler.DEFAULT_BUCKET_KEY,
ignorePauseDelay?: boolean): Timeout | undefined => {
const bucket = this.buckets[bucketKey];
if (bucket) {
const timeout = bucket[timeoutKey];
if (timeout) {
timeout.resume(ignorePauseDelay);
}
return timeout;
}
return;
}
/**
* Resumes the Timeout if it exists. Returns the Timeout that was resumed or
* undefined if the Timeout did not exist.
*/
resumeTimeoutOnDefaultBucket = (timeoutKey: string, ignorePauseDelay?: boolean): Timeout | undefined => {
return this.resumeTimeout(timeoutKey, Scheduler.DEFAULT_BUCKET_KEY, ignorePauseDelay);
}
/**
* Returns all timeouts that have not expired on the given bucket.
*/
getTimeoutsForBucket = (bucketKey: string): Timeout[] => {
const bucket = this.buckets[bucketKey];
let timeouts = [];
if (bucket) {
for (const key in bucket) {
timeouts.push(bucket[key]);
}
return timeouts;
}
return [];
}
/**
* Returns all timeouts that have not expired on the default bucket.
*/
getDefaultBucketTimeouts = (): Timeout[] => {
return this.getTimeoutsForBucket(Scheduler.DEFAULT_BUCKET_KEY);
}
/**
* Returns the timeout of the given key on the given bucket if it exists
* or undefined if it does not exist.
*/
getTimeoutForKey = (key: string, bucketKey: string = Scheduler.DEFAULT_BUCKET_KEY): Timeout | undefined => {
const bucket = this.buckets[bucketKey];
if (bucket) {
return bucket[key];
}
return;
}
/**
* Returns the timeout of the given key on the default bucket if it exists
* or undefined if it does not exist.
*/
getTimeoutForDefaultBucket = (key: string): Timeout | undefined => {
return this.getTimeoutForKey(key);
}
}