@buenos-nachos/time-sync 0.5.4 → 0.6.0

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.
package/src/TimeSync.ts DELETED
@@ -1,691 +0,0 @@
1
- import { ReadonlyDate } from "./ReadonlyDate";
2
-
3
- /**
4
- * A collection of commonly-needed intervals (all defined in milliseconds).
5
- */
6
- // Doing type assertion on the static numeric values to prevent compiler from
7
- // over-inferring the types, and exposing too much info to end users
8
- export const refreshRates = Object.freeze({
9
- /**
10
- * Indicates that a subscriber does not strictly need updates, but is still
11
- * allowed to be updated if it would keep it in sync with other subscribers.
12
- *
13
- * If all subscribers use this update interval, TimeSync will never dispatch
14
- * any updates.
15
- */
16
- idle: Number.POSITIVE_INFINITY,
17
-
18
- halfSecond: 500 as number,
19
- oneSecond: 1000 as number,
20
- thirtySeconds: 30_000 as number,
21
- oneMinute: 60 * 1000,
22
- fiveMinutes: 5 * 60 * 1000,
23
- oneHour: 60 * 60 * 1000,
24
- }) satisfies Record<string, number>;
25
-
26
- /**
27
- * The set of readonly options that the TimeSync has been configured with.
28
- */
29
- export interface Configuration {
30
- /**
31
- * Indicates whether the TimeSync instance should be frozen for Snapshot
32
- * tests. Highly encouraged that you use this together with
33
- * `initialDate`.
34
- *
35
- * Defaults to false.
36
- */
37
- readonly freezeUpdates: boolean;
38
-
39
- /**
40
- * The minimum refresh interval (in milliseconds) to use when dispatching
41
- * interval-based state updates.
42
- *
43
- * If a value smaller than this is specified when trying to set up a new
44
- * subscription, this minimum will be used instead.
45
- *
46
- * It is highly recommended that you only modify this value if you have a
47
- * good reason. Updating this value to be too low can make the event loop
48
- * get really hot and really tank performance elsewhere in an application.
49
- *
50
- * Defaults to 200ms.
51
- */
52
- readonly minimumRefreshIntervalMs: number;
53
-
54
- /**
55
- * Indicates whether the same `onUpdate` callback (by reference) should be
56
- * called multiple time if registered by multiple systems.
57
- *
58
- * If this value is flipped to false, each onUpdate callback will receive
59
- * the subscription context for the FIRST subscriber that registered the
60
- * onUpdate callback.
61
- *
62
- * Defaults to true.
63
- */
64
- readonly allowDuplicateOnUpdateCalls: boolean;
65
- }
66
-
67
- /**
68
- * The set of options that can be used to instantiate a TimeSync.
69
- */
70
- export interface InitOptions extends Configuration {
71
- /**
72
- * The Date object to use when initializing TimeSync to make the
73
- * constructor more pure and deterministic.
74
- */
75
- readonly initialDate: Date;
76
- }
77
-
78
- /**
79
- * An object used to initialize a new subscription for TimeSync.
80
- */
81
- export interface SubscriptionInitOptions {
82
- /**
83
- * The maximum update interval that a subscriber needs. A value of
84
- * Number.POSITIVE_INFINITY indicates that the subscriber does not strictly
85
- * need any updates (though they may still happen based on other
86
- * subscribers).
87
- *
88
- * TimeSync always dispatches updates based on the lowest update interval
89
- * among all subscribers.
90
- *
91
- * For example, let's say that we have these three subscribers:
92
- * 1. A - Needs updates no slower than 500ms
93
- * 2. B – Needs updates no slower than 1000ms
94
- * 3. C – Uses interval of Infinity (does not strictly need an update)
95
- *
96
- * A, B, and C will all be updated at a rate of 500ms. If A unsubscribes,
97
- * then B and C will shift to being updated every 1000ms. If B unsubscribes
98
- * after A, updates will pause completely until a new subscriber gets
99
- * added, and it has a non-infinite interval.
100
- */
101
- readonly targetRefreshIntervalMs: number;
102
-
103
- /**
104
- * The callback to call when a new state update needs to be flushed amongst
105
- * all subscribers.
106
- */
107
- readonly onUpdate: OnTimeSyncUpdate;
108
- }
109
-
110
- /**
111
- * A complete snapshot of the user-relevant internal state from TimeSync. This
112
- * value is treated as immutable at both runtime and compile time.
113
- */
114
- export interface Snapshot {
115
- /**
116
- * The date that TimeSync last processed. This will always match the date that
117
- * was last dispatched to all subscribers, but if no updates have been issued,
118
- * this value will match the date used to instantiate the TimeSync.
119
- */
120
- readonly date: ReadonlyDate;
121
-
122
- /**
123
- * The monotonic milliseconds that elapsed between the TimeSync being
124
- * instantiated and the last update being dispatched.
125
- *
126
- * Will be null if no updates have ever been dispatched.
127
- */
128
- readonly lastUpdatedAtMs: number | null;
129
-
130
- /**
131
- * The number of subscribers registered with TimeSync.
132
- */
133
- readonly subscriberCount: number;
134
-
135
- /**
136
- * The configuration options used when instantiating the TimeSync instance.
137
- * The value is guaranteed to be stable for the entire lifetime of TimeSync.
138
- */
139
- readonly config: Configuration;
140
- }
141
-
142
- /**
143
- * An object with information about a specific subscription registered with
144
- * TimeSync. The entire context is frozen at runtime.
145
- */
146
- export interface SubscriptionContext {
147
- /**
148
- * A reference to the TimeSync instance that the subscription was registered
149
- * with.
150
- */
151
- readonly timeSync: TimeSync;
152
-
153
- /**
154
- * The effective interval that the subscription is updating at. This may be a
155
- * value larger than than the target refresh interval, depending on whether
156
- * TimeSync was configured with a minimum refresh value.
157
- */
158
- readonly refreshIntervalMs: number;
159
-
160
- /**
161
- * The unsubscribe callback associated with a subscription. This is the same
162
- * callback returned by `TimeSync.subscribe`.
163
- */
164
- readonly unsubscribe: () => void;
165
-
166
- /**
167
- * The monotonic milliseconds that elapsed between the TimeSync being
168
- * instantiated and the subscription being registered.
169
- */
170
- readonly registeredAtMs: number;
171
- }
172
-
173
- /**
174
- * The callback to call when a new state update is ready to be dispatched.
175
- */
176
- export type OnTimeSyncUpdate = (
177
- newDate: ReadonlyDate,
178
- context: SubscriptionContext,
179
- ) => void;
180
-
181
- interface TimeSyncApi {
182
- /**
183
- * Subscribes an external system to TimeSync.
184
- *
185
- * The same callback (by reference) is allowed to be registered multiple
186
- * times, either for the same update interval, or different update
187
- * intervals. Depending on how TimeSync is instantiated, it may choose to
188
- * de-duplicate these function calls on each round of updates.
189
- *
190
- * If a value of Number.POSITIVE_INFINITY is used, the subscription will be
191
- * considered "idle". Idle subscriptions cannot trigger updates on their
192
- * own, but can stay in the loop as otherupdates get dispatched from via
193
- * other subscriptions.
194
- *
195
- * Consider using the refreshRates object from this package for a set of
196
- * commonly-used intervals.
197
- *
198
- * @throws {RangeError} If the provided interval is neither a positive
199
- * integer nor positive infinity.
200
- * @returns An unsubscribe callback. Calling the callback more than once
201
- * results in a no-op.
202
- */
203
- subscribe: (options: SubscriptionInitOptions) => () => void;
204
-
205
- /**
206
- * Allows an external system to pull an immutable snapshot of some of the
207
- * internal state inside TimeSync. The snapshot is frozen at runtime and
208
- * cannot be mutated.
209
- *
210
- * @returns An object with multiple properties describing the TimeSync.
211
- */
212
- getStateSnapshot: () => Snapshot;
213
-
214
- /**
215
- * Resets all internal state in the TimeSync, and handles all cleanup for
216
- * subscriptions and intervals previously set up. Configuration values are
217
- * retained.
218
- *
219
- * This method can be used as a dispose method for a locally-scoped
220
- * TimeSync (a TimeSync with no subscribers is safe to garbage-collect
221
- * without any risks of memory leaks). It can also be used to reset a global
222
- * TimeSync to its initial state for certain testing setups.
223
- */
224
- clearAll: () => void;
225
- }
226
-
227
- /**
228
- * Even though both the browser and the server are able to give monotonic times
229
- * that are at least as precise as a nanosecond, we're using milliseconds for
230
- * consistency with useInterval, which cannot be more precise than a
231
- * millisecond.
232
- */
233
- function getMonotonicTimeMs(): number {
234
- // If we're on the server, we can use process.hrtime, which is defined for
235
- // Node, Deno, and Bun
236
- if (typeof window === "undefined") {
237
- const timeInNanoseconds = process.hrtime.bigint();
238
- return Number(timeInNanoseconds / 1000n);
239
- }
240
-
241
- // Otherwise, we need to get the high-resolution timestamp from the browser.
242
- // This value is fractional and goes to nine decimal places
243
- const highResTimestamp = window.performance.now();
244
- return Math.floor(highResTimestamp);
245
- }
246
-
247
- /**
248
- * This function is just a convenience for us to sidestep some problems around
249
- * TypeScript's LSP and Object.freeze. Because Object.freeze can accept any
250
- * arbitrary type, it basically acts as a "type boundary" between the left and
251
- * right sides of any snapshot assignments.
252
- *
253
- * That means that if you rename a property a a value that is passed to
254
- * Object.freeze, the LSP can't auto-rename it, and you potentially get missing
255
- * properties. This is a bit hokey, but because the function is defined strictly
256
- * in terms of concrete snapshots, any value passed to this function won't have
257
- * to worry about mismatches.
258
- */
259
- function freezeSnapshot(snap: Snapshot): Snapshot {
260
- if (!Object.isFrozen(snap.config)) {
261
- Object.freeze(snap.config);
262
- }
263
- if (!Object.isFrozen(snap)) {
264
- Object.freeze(snap);
265
- }
266
- return snap;
267
- }
268
-
269
- const defaultMinimumRefreshIntervalMs = 200;
270
-
271
- /**
272
- * One thing that was considered was giving TimeSync the ability to flip which
273
- * kinds of dates it uses, and let it use native dates instead of readonly
274
- * dates. We type readonly dates as native dates for better interoperability
275
- * with pretty much every JavaScript library under the sun, but there is still a
276
- * big difference in runtime behavior. There is a risk that blocking mutations
277
- * could break some other library in other ways.
278
- *
279
- * That might be worth revisiting if we get user feedback, but right now, it
280
- * seems like an incredibly bad idea.
281
- *
282
- * 1. Any single mutation has a risk of breaking the entire integrity of the
283
- * system. If a consumer would try to mutate them, things SHOULD blow up by
284
- * default.
285
- * 2. Dates are a type of object that are far more read-heavy than write-heavy,
286
- * so the risks of breaking are generally lower
287
- * 3. If a user really needs a mutable version of the date, they can make a
288
- * mutable copy first via `const mutable = readonlyDate.toNativeDate()`
289
- *
290
- * The one case when turning off the readonly behavior would be good would be
291
- * if you're on a server that really needs to watch its garbage collection
292
- * output, and you the overhead from the readonly date is causing too much
293
- * pressure on resources. In that case, you could switch to native dates, but
294
- * you'd still need a LOT of trigger discipline to avoid mutations, especially
295
- * if you rely on outside libraries.
296
- */
297
- /**
298
- * TimeSync provides a centralized authority for working with time values in a
299
- * more structured way. It ensures all dependents for the time values stay in
300
- * sync with each other.
301
- *
302
- * (e.g., In a React codebase, you want multiple components that rely on time
303
- * values to update together, to avoid screen tearing and stale data for only
304
- * some parts of the screen.)
305
- */
306
- export class TimeSync implements TimeSyncApi {
307
- /**
308
- * The monotonic time in milliseconds from when the TimeSync instance was
309
- * first instantiated.
310
- */
311
- readonly #initializedAtMs: number;
312
-
313
- /**
314
- * Stores all refresh intervals actively associated with an onUpdate
315
- * callback (along with their associated unsubscribe callbacks).
316
- *
317
- * Supports storing the exact same callback-interval pairs multiple times,
318
- * in case multiple external systems need to subscribe with the exact same
319
- * data concerns. Because the functions themselves are used as keys, that
320
- * ensures that each callback will only be called once per update, no matter
321
- * how subscribers use it.
322
- *
323
- * Each map value should stay sorted by refresh interval, in ascending
324
- * order.
325
- *
326
- * ---
327
- *
328
- * This is a rare case where we actually REALLY need the readonly modifier
329
- * to avoid infinite loops. JavaScript's iterator protocol is really great
330
- * for making loops simple and type-safe, but because subscriptions have the
331
- * ability to add more subscriptions, we need to make an immutable version
332
- * of each array at some point to make sure that we're not iterating through
333
- * values forever
334
- *
335
- * We can choose to do that at one of two points:
336
- * 1. When adding a new subscription
337
- * 2. When dispatching a new round of updates
338
- *
339
- * Because this library assumes that dispatches will be much more common
340
- * than new subscriptions (a single subscription that subscribes for one
341
- * second will receive 360 updates in five minutes), operations should be
342
- * done to optimize that use case. So we should move the immutability costs
343
- * to the subscribe and unsubscribe operations.
344
- */
345
- #subscriptions: Map<OnTimeSyncUpdate, readonly SubscriptionContext[]>;
346
-
347
- /**
348
- * The latest public snapshot of TimeSync's internal state. The snapshot
349
- * should always be treated as an immutable value.
350
- */
351
- #latestSnapshot: Snapshot;
352
-
353
- /**
354
- * A cached version of the fastest interval currently registered with
355
- * TimeSync. Should always be derived from #subscriptions
356
- */
357
- #fastestRefreshInterval: number;
358
-
359
- /**
360
- * Used for both its intended purpose (creating interval), but also as a
361
- * janky version of setTimeout. Also, all versions of setInterval are
362
- * monotonic, so we don't have to do anything special for it.
363
- *
364
- * There are a few times when we need timeout-like logic, but if we use
365
- * setInterval for everything, we have fewer IDs to juggle, and less risk of
366
- * things getting out of sync.
367
- *
368
- * Type defined like this to support client and server behavior. Node.js
369
- * uses its own custom timeout type, but Deno, Bun, and the browser all use
370
- * the number type.
371
- */
372
- #intervalId: NodeJS.Timeout | number | undefined;
373
-
374
- constructor(options?: Partial<InitOptions>) {
375
- const {
376
- initialDate,
377
- freezeUpdates = false,
378
- allowDuplicateOnUpdateCalls = true,
379
- minimumRefreshIntervalMs = defaultMinimumRefreshIntervalMs,
380
- } = options ?? {};
381
-
382
- const isMinValid =
383
- Number.isInteger(minimumRefreshIntervalMs) &&
384
- minimumRefreshIntervalMs > 0;
385
- if (!isMinValid) {
386
- throw new RangeError(
387
- `Minimum refresh interval must be a positive integer (received ${minimumRefreshIntervalMs} ms)`,
388
- );
389
- }
390
-
391
- this.#subscriptions = new Map();
392
- this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
393
- this.#intervalId = undefined;
394
- this.#initializedAtMs = getMonotonicTimeMs();
395
-
396
- let date: ReadonlyDate;
397
- if (initialDate instanceof ReadonlyDate) {
398
- date = initialDate;
399
- } else if (initialDate instanceof Date) {
400
- date = new ReadonlyDate(initialDate);
401
- } else {
402
- date = new ReadonlyDate();
403
- }
404
-
405
- this.#latestSnapshot = freezeSnapshot({
406
- date,
407
- subscriberCount: 0,
408
- lastUpdatedAtMs: null,
409
- config: {
410
- freezeUpdates,
411
- minimumRefreshIntervalMs,
412
- allowDuplicateOnUpdateCalls,
413
- },
414
- });
415
- }
416
-
417
- #notifyAllSubscriptions(): void {
418
- // It's more important that we copy the date object into a separate
419
- // variable here than normal, because need make sure the `this` context
420
- // can't magically change between updates and cause subscribers to
421
- // receive different values
422
- const { date, config } = this.#latestSnapshot;
423
-
424
- const subscriptionsPaused =
425
- config.freezeUpdates ||
426
- this.#subscriptions.size === 0 ||
427
- this.#fastestRefreshInterval === Number.POSITIVE_INFINITY;
428
- if (subscriptionsPaused) {
429
- return;
430
- }
431
-
432
- /**
433
- * Two things:
434
- * 1. Even though the context arrays are defined as readonly (which
435
- * removes on the worst edge cases during dispatching), the
436
- * subscriptions map itself is still mutable, so there are a few edge
437
- * cases we need to deal with. While the risk of infinite loops should
438
- * be much lower, there's still the risk that an onUpdate callback could
439
- * add a subscriber for an interval that wasn't registered before, which
440
- * the iterator protocol will pick up. Need to make a local,
441
- * fixed-length copy of the map entries before starting iteration. Any
442
- * subscriptions added during update will just have to wait until the
443
- * next round of updates.
444
- *
445
- * 2. The trade off of the serialization is that we do lose the ability
446
- * to auto-break the loop if one of the subscribers ends up resetting
447
- * all state, because we'll still have local copies of entries. We need
448
- * to check on each iteration to see if we should continue.
449
- */
450
- const subsBeforeUpdate = this.#subscriptions;
451
- const localEntries = Array.from(subsBeforeUpdate);
452
- outer: for (const [onUpdate, subs] of localEntries) {
453
- for (const ctx of subs) {
454
- // We're not doing anything more sophisticated here because
455
- // we're assuming that any systems that can clear out the
456
- // subscriptions will handle cleaning up each context, too
457
- const wasClearedBetweenUpdates = subsBeforeUpdate.size === 0;
458
- if (wasClearedBetweenUpdates) {
459
- break outer;
460
- }
461
-
462
- onUpdate(date, ctx);
463
- if (!config.allowDuplicateOnUpdateCalls) {
464
- continue outer;
465
- }
466
- }
467
- }
468
- }
469
-
470
- /**
471
- * The logic that should happen at each step in TimeSync's active interval.
472
- *
473
- * Defined as an arrow function so that we can just pass it directly to
474
- * setInterval without needing to make a new wrapper function each time. We
475
- * don't have many situations where we can lose the `this` context, but this
476
- * is one of them.
477
- */
478
- readonly #onTick = (): void => {
479
- const { config } = this.#latestSnapshot;
480
- if (config.freezeUpdates) {
481
- // Defensive step to make sure that an invalid tick wasn't started
482
- clearInterval(this.#intervalId);
483
- this.#intervalId = undefined;
484
- return;
485
- }
486
-
487
- // onTick is expected to be called in response to monotonic time changes
488
- // (either from calculating them manually to decide when to call onTick
489
- // synchronously or from letting setInterval handle the calls). So while
490
- // this edge case should basically be impossible, we need to make sure that
491
- // we always dispatch a date, even if its time is exactly the same.
492
- this.#latestSnapshot = freezeSnapshot({
493
- ...this.#latestSnapshot,
494
- date: new ReadonlyDate(),
495
- lastUpdatedAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
496
- });
497
- this.#notifyAllSubscriptions();
498
- };
499
-
500
- #onFastestIntervalChange(): void {
501
- const fastest = this.#fastestRefreshInterval;
502
- const { lastUpdatedAtMs, config } = this.#latestSnapshot;
503
-
504
- const updatesShouldStop =
505
- config.freezeUpdates ||
506
- this.#subscriptions.size === 0 ||
507
- fastest === Number.POSITIVE_INFINITY;
508
- if (updatesShouldStop) {
509
- clearInterval(this.#intervalId);
510
- this.#intervalId = undefined;
511
- return;
512
- }
513
-
514
- const newTime = getMonotonicTimeMs();
515
- const elapsed = newTime - (lastUpdatedAtMs ?? this.#initializedAtMs);
516
- const timeBeforeNextUpdate = fastest - elapsed;
517
-
518
- // Clear previous interval no matter what just to be on the safe side
519
- clearInterval(this.#intervalId);
520
-
521
- if (timeBeforeNextUpdate <= 0) {
522
- this.#onTick();
523
- this.#intervalId = setInterval(this.#onTick, fastest);
524
- return;
525
- }
526
-
527
- // Most common case for this branch is the very first subscription
528
- // getting added, but there's still the small chance that the fastest
529
- // interval could change right after an update got flushed, so there would
530
- // be zero elapsed time to worry about
531
- if (timeBeforeNextUpdate === fastest) {
532
- this.#intervalId = setInterval(this.#onTick, timeBeforeNextUpdate);
533
- return;
534
- }
535
-
536
- // Otherwise, use setInterval as pseudo-timeout to resolve the remaining
537
- // time as a one-time update, and then go back to using normal intervals
538
- this.#intervalId = setInterval(() => {
539
- clearInterval(this.#intervalId);
540
-
541
- // Need to set up interval before ticking in the tiny, tiny chance
542
- // that ticking would cause the TimeSync instance to be reset. We
543
- // don't want to start a new interval right after we've lost our
544
- // ability to do cleanup. The timer won't start getting processed
545
- // until the function leaves scope anyway
546
- this.#intervalId = setInterval(this.#onTick, fastest);
547
- this.#onTick();
548
- }, timeBeforeNextUpdate);
549
- }
550
-
551
- #updateFastestInterval(): void {
552
- const { config } = this.#latestSnapshot;
553
- if (config.freezeUpdates) {
554
- this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
555
- return;
556
- }
557
-
558
- // This setup requires that every interval array stay sorted. It
559
- // immediately falls apart if this isn't guaranteed.
560
- const prevFastest = this.#fastestRefreshInterval;
561
- let newFastest = Number.POSITIVE_INFINITY;
562
- for (const contexts of this.#subscriptions.values()) {
563
- const subFastest =
564
- contexts[0]?.refreshIntervalMs ?? Number.POSITIVE_INFINITY;
565
- if (subFastest < newFastest) {
566
- newFastest = subFastest;
567
- }
568
- }
569
-
570
- this.#fastestRefreshInterval = newFastest;
571
- if (prevFastest !== newFastest) {
572
- this.#onFastestIntervalChange();
573
- }
574
- }
575
-
576
- subscribe(options: SubscriptionInitOptions): () => void {
577
- // Destructuring properties so that they can't be fiddled with after
578
- // this function call ends
579
- const { targetRefreshIntervalMs, onUpdate } = options;
580
- const { minimumRefreshIntervalMs } = this.#latestSnapshot.config;
581
-
582
- const isTargetValid =
583
- targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
584
- (Number.isInteger(targetRefreshIntervalMs) &&
585
- targetRefreshIntervalMs > 0);
586
- if (!isTargetValid) {
587
- throw new Error(
588
- `Target refresh interval must be positive infinity or a positive integer (received ${targetRefreshIntervalMs} ms)`,
589
- );
590
- }
591
-
592
- const subsOnSetup = this.#subscriptions;
593
- let subscribed = true;
594
- const ctx: SubscriptionContext = {
595
- timeSync: this,
596
- registeredAtMs: getMonotonicTimeMs() - this.#initializedAtMs,
597
- refreshIntervalMs: Math.max(
598
- minimumRefreshIntervalMs,
599
- targetRefreshIntervalMs,
600
- ),
601
-
602
- unsubscribe: () => {
603
- // Not super conventional, but basically using try/finally as a form of
604
- // Go's defer. There are a lot of branches we need to worry about for
605
- // the unsubscribe callback, and we need to make sure we flip subscribed
606
- // to false after each one
607
- try {
608
- if (!subscribed || this.#subscriptions !== subsOnSetup) {
609
- return;
610
- }
611
- const contexts = subsOnSetup.get(onUpdate);
612
- if (contexts === undefined) {
613
- return;
614
- }
615
- const filtered = contexts.filter(
616
- (c) => c.unsubscribe !== ctx.unsubscribe,
617
- );
618
- if (filtered.length === contexts.length) {
619
- return;
620
- }
621
-
622
- const dropped = Math.max(0, this.#latestSnapshot.subscriberCount - 1);
623
- this.#latestSnapshot = freezeSnapshot({
624
- ...this.#latestSnapshot,
625
- subscriberCount: dropped,
626
- });
627
-
628
- if (filtered.length > 0) {
629
- // No need to sort on removal because everything gets sorted as
630
- // it enters the subscriptions map
631
- subsOnSetup.set(onUpdate, filtered);
632
- } else {
633
- subsOnSetup.delete(onUpdate);
634
- }
635
-
636
- this.#updateFastestInterval();
637
- } finally {
638
- subscribed = false;
639
- }
640
- },
641
- };
642
- Object.freeze(ctx);
643
-
644
- // We need to make sure that each array for tracking subscriptions is
645
- // readonly, and because dispatching updates should be far more common than
646
- // adding subscriptions, we're placing the immutable copying here to
647
- // minimize overall pressure on the system.
648
- let newContexts: SubscriptionContext[];
649
- const prevContexts = subsOnSetup.get(onUpdate);
650
- if (prevContexts !== undefined) {
651
- newContexts = [...prevContexts, ctx];
652
- } else {
653
- newContexts = [ctx];
654
- }
655
-
656
- subsOnSetup.set(onUpdate, newContexts);
657
- newContexts.sort((c1, c2) => c1.refreshIntervalMs - c2.refreshIntervalMs);
658
-
659
- this.#latestSnapshot = freezeSnapshot({
660
- ...this.#latestSnapshot,
661
- subscriberCount: this.#latestSnapshot.subscriberCount + 1,
662
- });
663
-
664
- this.#updateFastestInterval();
665
- return ctx.unsubscribe;
666
- }
667
-
668
- getStateSnapshot(): Snapshot {
669
- return this.#latestSnapshot;
670
- }
671
-
672
- clearAll(): void {
673
- clearInterval(this.#intervalId);
674
- this.#intervalId = undefined;
675
- this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
676
-
677
- // As long as we clean things the internal state, it's safe not to
678
- // bother calling each unsubscribe callback. Not calling them one by
679
- // one actually has much better time complexity
680
- this.#subscriptions.clear();
681
-
682
- // We swap the map out so that the unsubscribe callbacks can detect
683
- // whether their functionality is still relevant
684
- this.#subscriptions = new Map();
685
-
686
- this.#latestSnapshot = freezeSnapshot({
687
- ...this.#latestSnapshot,
688
- subscriberCount: 0,
689
- });
690
- }
691
- }
package/src/index.ts DELETED
@@ -1,12 +0,0 @@
1
- // Not using wildcard syntax to make final exported dependencies more obvious
2
- export { ReadonlyDate } from "./ReadonlyDate";
3
- export {
4
- type Configuration,
5
- type InitOptions,
6
- type OnTimeSyncUpdate,
7
- refreshRates,
8
- type Snapshot,
9
- type SubscriptionContext,
10
- type SubscriptionInitOptions,
11
- TimeSync,
12
- } from "./TimeSync";