@buenos-nachos/time-sync 0.1.1

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,583 @@
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 type Configuration = Readonly<{
30
+ /**
31
+ * Indicates whether the TimeSync instance should be frozen for Snapshot
32
+ * tests.
33
+ *
34
+ * Defaults to false.
35
+ */
36
+ freezeUpdates: boolean;
37
+
38
+ /**
39
+ * The minimum refresh interval (in milliseconds) to use when dispatching
40
+ * interval-based state updates.
41
+ *
42
+ * If a value smaller than this is specified when trying to set up a new
43
+ * subscription, this minimum will be used instead.
44
+ *
45
+ * It is highly recommended that you only modify this value if you have a
46
+ * good reason. Updating this value to be too low and make the event loop
47
+ * get really hot and really tank performance elsewhere in an application.
48
+ *
49
+ * Defaults to 200ms.
50
+ */
51
+ minimumRefreshIntervalMs: number;
52
+
53
+ /**
54
+ * Indicates whether the same `onUpdate` callback (by reference) should be
55
+ * called multiple time if registered by multiple systems.
56
+ *
57
+ * Defaults to false.
58
+ */
59
+ allowDuplicateOnUpdateCalls: boolean;
60
+ }>;
61
+
62
+ /**
63
+ * The set of options that can be used to instantiate a TimeSync.
64
+ */
65
+ export type InitOptions = Readonly<
66
+ Configuration & {
67
+ /**
68
+ * Indicates whether the TimeSync instance should be frozen for snapshot
69
+ * tests. Highly encouraged that you use this together with
70
+ * `initialDate`.
71
+ *
72
+ * Defaults to false.
73
+ */
74
+ // Duplicated property to override the LSP comment
75
+ freezeUpdates: boolean;
76
+
77
+ /**
78
+ * The Date object to use when initializing TimeSync to make the
79
+ * constructor more pure and deterministic.
80
+ */
81
+ initialDate: Date;
82
+ }
83
+ >;
84
+
85
+ /**
86
+ * The callback to call when a new state update is ready to be dispatched.
87
+ */
88
+ export type OnTimeSyncUpdate = (dateSnapshot: ReadonlyDate) => void;
89
+
90
+ /**
91
+ * An object used to initialize a new subscription for TimeSync.
92
+ */
93
+ export type SubscriptionOptions = Readonly<{
94
+ /**
95
+ * The maximum update interval that a subscriber needs. A value of
96
+ * Number.POSITIVE_INFINITY indicates that the subscriber does not strictly
97
+ * need any updates (though they may still happen based on other
98
+ * subscribers).
99
+ *
100
+ * TimeSync always dispatches updates based on the lowest update interval
101
+ * among all subscribers.
102
+ *
103
+ * For example, let's say that we have these three subscribers:
104
+ * 1. A - Needs updates no slower than 500ms
105
+ * 2. B – Needs updates no slower than 1000ms
106
+ * 3. C – Uses interval of Infinity (does not strictly need an update)
107
+ *
108
+ * A, B, and C will all be updated at a rate of 500ms. If A unsubscribes,
109
+ * then B and C will shift to being updated every 1000ms. If B unsubscribes
110
+ * after A, updates will pause completely until a new subscriber gets
111
+ * added, and it has a non-infinite interval.
112
+ */
113
+ targetRefreshIntervalMs: number;
114
+
115
+ /**
116
+ * The callback to call when a new state update needs to be flushed amongst
117
+ * all subscribers.
118
+ */
119
+ onUpdate: OnTimeSyncUpdate;
120
+ }>;
121
+
122
+ /**
123
+ * A complete snapshot of the user-relevant internal state from TimeSync. This
124
+ * value is treated as immutable at both runtime and compile time.
125
+ */
126
+ export type Snapshot = Readonly<{
127
+ /**
128
+ * The date that was last dispatched to all subscribers.
129
+ */
130
+ date: ReadonlyDate;
131
+
132
+ /**
133
+ * The number of subscribers registered with TimeSync.
134
+ */
135
+ subscriberCount: number;
136
+
137
+ /**
138
+ * The configuration options used when instantiating the TimeSync instance.
139
+ * The value is guaranteed to be stable for the entire lifetime of TimeSync.
140
+ */
141
+ config: Configuration;
142
+ }>;
143
+
144
+ interface TimeSyncApi {
145
+ /**
146
+ * Subscribes an external system to TimeSync.
147
+ *
148
+ * The same callback (by reference) is allowed to be registered multiple
149
+ * times, either for the same update interval, or different update
150
+ * intervals. Depending on how TimeSync is instantiated, it may choose to
151
+ * de-duplicate these function calls on each round of updates.
152
+ *
153
+ * @throws {RangeError} If the provided interval is not either a positive
154
+ * integer or positive infinity.
155
+ * @returns An unsubscribe callback. Calling the callback more than once
156
+ * results in a no-op.
157
+ */
158
+ subscribe: (options: SubscriptionOptions) => () => void;
159
+
160
+ /**
161
+ * Allows an external system to pull an immutable snapshot of some of the
162
+ * internal state inside TimeSync. The snapshot is frozen at runtime and
163
+ * cannot be mutated.
164
+ *
165
+ * @returns An object with multiple properties describing the TimeSync.
166
+ */
167
+ getStateSnapshot: () => Snapshot;
168
+
169
+ /**
170
+ * Resets all internal state in the TimeSync, and handles all cleanup for
171
+ * subscriptions and intervals previously set up. Configuration values are
172
+ * retained.
173
+ *
174
+ * This method can be used as a dispose method for a locally-scoped
175
+ * TimeSync (a TimeSync with no subscribers is safe to garbage-collect
176
+ * without any risks of memory leaks). It can also be used to reset a global
177
+ * TimeSync to its initial state for certain testing setups.
178
+ */
179
+ clearAll: () => void;
180
+ }
181
+
182
+ type SubscriptionEntry = Readonly<{
183
+ targetInterval: number;
184
+ unsubscribe: () => void;
185
+ }>;
186
+
187
+ /* biome-ignore lint:suspicious/noEmptyBlockStatements -- Rare case where we do
188
+ actually want a completely empty function body. */
189
+ function noOp(..._: readonly unknown[]): void {}
190
+
191
+ const defaultMinimumRefreshIntervalMs = 200;
192
+
193
+ /**
194
+ * One thing that was considered was giving TimeSync the ability to flip which
195
+ * kinds of dates it uses, and let it use native dates instead of readonly
196
+ * dates. We type readonly dates as native dates for better interoperability
197
+ * with pretty much every JavaScript library under the sun, but there is still a
198
+ * big difference in runtime behavior. There is a risk that blocking mutations
199
+ * could break some other library in other ways.
200
+ *
201
+ * That might be worth revisiting if we get user feedback, but right now, it
202
+ * seems like an incredibly bad idea.
203
+ *
204
+ * 1. Any single mutation has a risk of breaking the entire integrity of the
205
+ * system. If a consumer would try to mutate them, things SHOULD blow up by
206
+ * default.
207
+ * 2. Dates are a type of object that are far more read-heavy than write-heavy,
208
+ * so the risks of breaking are generally lower
209
+ * 3. If a user really needs a mutable version of the date, they can make a
210
+ * mutable copy first via `const mutable = readonlyDate.toNativeDate()`
211
+ *
212
+ * The one case when turning off the readonly behavior would be good would be
213
+ * if you're on a server that really needs to watch its garbage collection
214
+ * output, and you the overhead from the readonly date is causing too much
215
+ * pressure on resources. In that case, you could switch to native dates, but
216
+ * you'd still need a LOT of trigger discipline to avoid mutations, especially
217
+ * if you rely on outside libraries.
218
+ */
219
+ /**
220
+ * TimeSync provides a centralized authority for working with time values in a
221
+ * more structured way. It ensures all dependents for the time values stay in
222
+ * sync with each other.
223
+ *
224
+ * (e.g., In a React codebase, you want multiple components that rely on time
225
+ * values to update together, to avoid screen tearing and stale data for only
226
+ * some parts of the screen.)
227
+ */
228
+ export class TimeSync implements TimeSyncApi {
229
+ /**
230
+ * Stores all refresh intervals actively associated with an onUpdate
231
+ * callback (along with their associated unsubscribe callbacks).
232
+ *
233
+ * Supports storing the exact same callback-interval pairs multiple times,
234
+ * in case multiple external systems need to subscribe with the exact same
235
+ * data concerns. Because the functions themselves are used as keys, that
236
+ * ensures that each callback will only be called once per update, no matter
237
+ * how subscribers use it.
238
+ *
239
+ * Each map value should stay sorted by refresh interval, in ascending
240
+ * order.
241
+ */
242
+ #subscriptions: Map<OnTimeSyncUpdate, SubscriptionEntry[]>;
243
+
244
+ /**
245
+ * The latest public snapshot of TimeSync's internal state. The snapshot
246
+ * should always be treated as an immutable value.
247
+ */
248
+ #latestSnapshot: Snapshot;
249
+
250
+ /**
251
+ * A cached version of the fastest interval currently registered with
252
+ * TimeSync. Should always be derived from #subscriptions
253
+ */
254
+ #fastestRefreshInterval: number;
255
+
256
+ /**
257
+ * Used for both its intended purpose (creating interval), but also as a
258
+ * janky version of setTimeout.
259
+ *
260
+ * There are a few times when we need timeout-like logic, but if we use
261
+ * setInterval for everything, we have fewer IDs to juggle, and less risk of
262
+ * things getting out of sync.
263
+ *
264
+ * Type defined like this to support client and server behavior. Node.js
265
+ * uses its own custom timeout type, but Deno, Bun, and the browser all use
266
+ * the number type.
267
+ */
268
+ #intervalId: NodeJS.Timeout | number | undefined;
269
+
270
+ constructor(options?: Partial<InitOptions>) {
271
+ const {
272
+ initialDate,
273
+ freezeUpdates = false,
274
+ allowDuplicateOnUpdateCalls = false,
275
+ minimumRefreshIntervalMs = defaultMinimumRefreshIntervalMs,
276
+ } = options ?? {};
277
+
278
+ const isMinValid =
279
+ Number.isInteger(minimumRefreshIntervalMs) &&
280
+ minimumRefreshIntervalMs > 0;
281
+ if (!isMinValid) {
282
+ throw new RangeError(
283
+ `Minimum refresh interval must be a positive integer (received ${minimumRefreshIntervalMs} ms)`,
284
+ );
285
+ }
286
+
287
+ this.#subscriptions = new Map();
288
+ this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
289
+ this.#intervalId = undefined;
290
+
291
+ // Not defined inline to avoid wonkiness that Object.freeze introduces
292
+ // when you rename a property on a frozen object
293
+ const initialSnapshot: Snapshot = {
294
+ subscriberCount: 0,
295
+ date: initialDate ? new ReadonlyDate(initialDate) : new ReadonlyDate(),
296
+ config: Object.freeze({
297
+ freezeUpdates,
298
+ minimumRefreshIntervalMs,
299
+ allowDuplicateOnUpdateCalls,
300
+ }),
301
+ };
302
+ this.#latestSnapshot = Object.freeze(initialSnapshot);
303
+ }
304
+
305
+ #setSnapshot(update: Partial<Snapshot>): boolean {
306
+ const { date, subscriberCount, config } = this.#latestSnapshot;
307
+ if (config.freezeUpdates) {
308
+ return false;
309
+ }
310
+
311
+ // Avoiding both direct property assignment or spread syntax because
312
+ // Object.freeze causes weird TypeScript LSP issues around assignability
313
+ // where trying to rename a property. If you rename a property on a
314
+ // type, it WON'T rename the runtime properties. Object.freeze
315
+ // introduces an extra type boundary that break the linking
316
+ const updated: Snapshot = {
317
+ // Always reject any new configs because trying to remove them at
318
+ // the type level isn't worth it for an internal implementation
319
+ // detail
320
+ config,
321
+ date: update.date ?? date,
322
+ subscriberCount: update.subscriberCount ?? subscriberCount,
323
+ };
324
+
325
+ this.#latestSnapshot = Object.freeze(updated);
326
+ return true;
327
+ }
328
+
329
+ #notifyAllSubscriptions(): void {
330
+ // It's more important that we copy the date object into a separate
331
+ // variable here than normal, because need make sure the `this` context
332
+ // can't magically change between updates and cause subscribers to
333
+ // receive different values (e.g., one of the subscribers calls the
334
+ // invalidate method)
335
+ const { date, config } = this.#latestSnapshot;
336
+
337
+ // We still need to let the logic go through if the current fastest
338
+ // interval is Infinity, so that we can support letting any arbitrary
339
+ // consumer invalidate the date immediately
340
+ const subscriptionsPaused =
341
+ config.freezeUpdates || this.#subscriptions.size === 0;
342
+ if (subscriptionsPaused) {
343
+ return;
344
+ }
345
+
346
+ /**
347
+ * Two things for both paths:
348
+ * 1. We need to make sure that we do one-time serializations of the map
349
+ * entries into an array instead of constantly pulling from the map via
350
+ * the iterator protocol in the off chance that subscriptions add new
351
+ * subscriptions. We need to make infinite loops impossible. If new
352
+ * subscriptions get added, they'll just have to wait until the next
353
+ * update round.
354
+ *
355
+ * 2. The trade off of the serialization is that we do lose the ability
356
+ * to auto-break the loops if one of the subscribers ends up resetting
357
+ * all state, because we'll still have local copies of entries. We need
358
+ * to check on each iteration to see if we should continue.
359
+ */
360
+ if (config.allowDuplicateOnUpdateCalls) {
361
+ // Not super happy about this, but because each subscription array
362
+ // is mutable, we have to make an immutable copy of the count of
363
+ // each sub before starting any dispatches. If we wait until the
364
+ // inner loop to store the length of the subs before iterating over
365
+ // them, that's too late. It's possible that a subscription could
366
+ // cause data to be pushed to an array for a different interval
367
+ const entries = Array.from(
368
+ this.#subscriptions,
369
+ ([onUpdate, subs]) => [onUpdate, subs.length] as const,
370
+ );
371
+ outer: for (const [onUpdate, subCount] of entries) {
372
+ for (let i = 0; i < subCount; i++) {
373
+ const wasCleared = this.#subscriptions.size === 0;
374
+ if (wasCleared) {
375
+ break outer;
376
+ }
377
+ onUpdate(date);
378
+ }
379
+ }
380
+ return;
381
+ }
382
+
383
+ const funcs = [...this.#subscriptions.keys()];
384
+ for (const onUpdate of funcs) {
385
+ const wasCleared = this.#subscriptions.size === 0;
386
+ if (wasCleared) {
387
+ break;
388
+ }
389
+ onUpdate(date);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * The logic that should happen at each step in TimeSync's active interval.
395
+ *
396
+ * Defined as an arrow function so that we can just pass it directly to
397
+ * setInterval without needing to make a new wrapper function each time. We
398
+ * don't have many situations where we can lose the `this` context, but this
399
+ * is one of them.
400
+ */
401
+ readonly #onTick = (): void => {
402
+ // Defensive step to make sure that an invalid tick wasn't started
403
+ const { config } = this.#latestSnapshot;
404
+ if (config.freezeUpdates) {
405
+ clearInterval(this.#intervalId);
406
+ this.#intervalId = undefined;
407
+ return;
408
+ }
409
+
410
+ const wasChanged = this.#setSnapshot({ date: new ReadonlyDate() });
411
+ if (wasChanged) {
412
+ this.#notifyAllSubscriptions();
413
+ }
414
+ };
415
+
416
+ #onFastestIntervalChange(): void {
417
+ const fastest = this.#fastestRefreshInterval;
418
+ const { date, config } = this.#latestSnapshot;
419
+ const updatesShouldStop =
420
+ config.freezeUpdates || fastest === Number.POSITIVE_INFINITY;
421
+ if (updatesShouldStop) {
422
+ clearInterval(this.#intervalId);
423
+ this.#intervalId = undefined;
424
+ return;
425
+ }
426
+
427
+ const elapsed = new ReadonlyDate().getTime() - date.getTime();
428
+ const timeBeforeNextUpdate = fastest - elapsed;
429
+
430
+ // Clear previous interval sight unseen just to be on the safe side
431
+ clearInterval(this.#intervalId);
432
+
433
+ if (timeBeforeNextUpdate <= 0) {
434
+ const wasChanged = this.#setSnapshot({ date: new ReadonlyDate() });
435
+ if (wasChanged) {
436
+ this.#notifyAllSubscriptions();
437
+ }
438
+ this.#intervalId = setInterval(this.#onTick, fastest);
439
+ return;
440
+ }
441
+
442
+ // Most common case for this branch is the very first subscription
443
+ // getting added, but there's still the small chance that the fastest
444
+ // interval could change right after an update got flushed
445
+ if (timeBeforeNextUpdate === fastest) {
446
+ this.#intervalId = setInterval(this.#onTick, timeBeforeNextUpdate);
447
+ return;
448
+ }
449
+
450
+ // Otherwise, use interval as pseudo-timeout, and then go back to using
451
+ // it as a normal interval afterwards
452
+ this.#intervalId = setInterval(() => {
453
+ clearInterval(this.#intervalId);
454
+
455
+ // Need to set up interval before ticking in the tiny, tiny chance
456
+ // that ticking would cause the TimeSync instance to be reset. We
457
+ // don't want to start a new interval right after we've lost our
458
+ // ability to do cleanup. The timer won't start getting processed
459
+ // until the function leaves scope anyway
460
+ this.#intervalId = setInterval(this.#onTick, fastest);
461
+ this.#onTick();
462
+ }, timeBeforeNextUpdate);
463
+ }
464
+
465
+ #updateFastestInterval(): void {
466
+ const { config } = this.#latestSnapshot;
467
+ if (config.freezeUpdates) {
468
+ this.#fastestRefreshInterval = Number.POSITIVE_INFINITY;
469
+ return;
470
+ }
471
+
472
+ const prevFastest = this.#fastestRefreshInterval;
473
+ let newFastest = Number.POSITIVE_INFINITY;
474
+
475
+ // This setup requires that every interval array stay sorted. It
476
+ // immediately falls apart if this isn't guaranteed.
477
+ for (const entries of this.#subscriptions.values()) {
478
+ const subFastest = entries[0]?.targetInterval ?? Number.POSITIVE_INFINITY;
479
+ if (subFastest < newFastest) {
480
+ newFastest = subFastest;
481
+ }
482
+ }
483
+
484
+ this.#fastestRefreshInterval = newFastest;
485
+ if (prevFastest !== newFastest) {
486
+ this.#onFastestIntervalChange();
487
+ }
488
+ }
489
+
490
+ subscribe(sh: SubscriptionOptions): () => void {
491
+ const { config } = this.#latestSnapshot;
492
+ if (config.freezeUpdates) {
493
+ return noOp;
494
+ }
495
+
496
+ // Destructuring properties so that they can't be fiddled with after
497
+ // this function call ends
498
+ const { targetRefreshIntervalMs, onUpdate } = sh;
499
+
500
+ const isTargetValid =
501
+ targetRefreshIntervalMs === Number.POSITIVE_INFINITY ||
502
+ (Number.isInteger(targetRefreshIntervalMs) &&
503
+ targetRefreshIntervalMs > 0);
504
+ if (!isTargetValid) {
505
+ throw new Error(
506
+ `Target refresh interval must be positive infinity or a positive integer (received ${targetRefreshIntervalMs} ms)`,
507
+ );
508
+ }
509
+
510
+ const subsOnSetup = this.#subscriptions;
511
+ let subscribed = true;
512
+ const unsubscribe = (): void => {
513
+ if (!subscribed || this.#subscriptions !== subsOnSetup) {
514
+ subscribed = false;
515
+ return;
516
+ }
517
+
518
+ const entries = subsOnSetup.get(onUpdate);
519
+ if (entries === undefined) {
520
+ return;
521
+ }
522
+ const matchIndex = entries.findIndex(
523
+ (e) => e.unsubscribe === unsubscribe,
524
+ );
525
+ if (matchIndex === -1) {
526
+ return;
527
+ }
528
+ // No need to sort on removal because everything gets sorted as it
529
+ // enters the subscriptions map
530
+ entries.splice(matchIndex, 1);
531
+ if (entries.length === 0) {
532
+ subsOnSetup.delete(onUpdate);
533
+ this.#updateFastestInterval();
534
+ }
535
+
536
+ void this.#setSnapshot({
537
+ subscriberCount: Math.max(0, this.#latestSnapshot.subscriberCount - 1),
538
+ });
539
+ subscribed = false;
540
+ };
541
+
542
+ let entries = subsOnSetup.get(onUpdate);
543
+ if (entries === undefined) {
544
+ entries = [];
545
+ subsOnSetup.set(onUpdate, entries);
546
+ }
547
+
548
+ const targetInterval = Math.max(
549
+ config.minimumRefreshIntervalMs,
550
+ targetRefreshIntervalMs,
551
+ );
552
+ entries.push({ unsubscribe, targetInterval });
553
+ entries.sort((e1, e2) => e1.targetInterval - e2.targetInterval);
554
+
555
+ void this.#setSnapshot({
556
+ subscriberCount: this.#latestSnapshot.subscriberCount + 1,
557
+ });
558
+
559
+ this.#updateFastestInterval();
560
+ return unsubscribe;
561
+ }
562
+
563
+ getStateSnapshot(): Snapshot {
564
+ return this.#latestSnapshot;
565
+ }
566
+
567
+ clearAll(): void {
568
+ clearInterval(this.#intervalId);
569
+ this.#intervalId = undefined;
570
+ this.#fastestRefreshInterval = 0;
571
+
572
+ // If we know for a fact that we're going to toss everything, we don't
573
+ // need to bother iterating through the unsubscribe callbacks. We can
574
+ // just swap in a new map, and then completely erase the old map (likely
575
+ // leaning into more efficient code than we could write). As long as the
576
+ // unsubscribe callbacks are set up to check a local version of the
577
+ // subscriptions, this won't ever cause problems.
578
+ const subsBefore = this.#subscriptions;
579
+ this.#subscriptions = new Map();
580
+ subsBefore.clear();
581
+ void this.#setSnapshot({ subscriberCount: 0 });
582
+ }
583
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
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 SubscriptionOptions,
10
+ TimeSync,
11
+ } from "./TimeSync";
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../../tsconfig.json"
3
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: "./src/index.ts",
5
+ platform: "neutral",
6
+ dts: true,
7
+ sourcemap: true,
8
+ format: ["cjs", "esm"],
9
+ });