UNPKG

@data-client/core

Version:

Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch

193 lines (177 loc) 6.43 kB
import type { EndpointInterface } from '@data-client/normalizr'; import ConnectionListener from './ConnectionListener.js'; import DefaultConnectionListener from './DefaultConnectionListener.js'; import type { Subscription } from './SubscriptionManager.js'; import type Controller from '../controller/Controller.js'; import type { SubscribeAction } from '../types.js'; /** * PollingSubscription keeps a given resource updated by * dispatching a fetch at a rate equal to the minimum update * interval requested. * * @see https://dataclient.io/docs/api/PollingSubscription */ export default class PollingSubscription implements Subscription { declare protected readonly endpoint: EndpointInterface; declare protected readonly args: readonly any[]; declare protected readonly key: string; declare protected frequency: number; protected frequencyHistogram: Map<number, number> = new Map(); declare protected controller: Controller; declare protected intervalId?: ReturnType<typeof setInterval>; declare protected lastIntervalId?: ReturnType<typeof setInterval>; declare protected startId?: ReturnType<typeof setTimeout>; declare private connectionListener: ConnectionListener; constructor( action: Omit<SubscribeAction, 'type'>, controller: Controller, connectionListener?: ConnectionListener, ) { if (action.endpoint.pollFrequency === undefined) throw new Error('frequency needed for polling subscription'); this.endpoint = action.endpoint; this.frequency = action.endpoint.pollFrequency; this.args = action.args; this.key = action.key; this.frequencyHistogram.set(this.frequency, 1); this.controller = controller; this.connectionListener = connectionListener || new DefaultConnectionListener(); // Kickstart running since this is initialized after the online notif is sent if (this.connectionListener.isOnline()) { this.onlineListener(); } else { this.offlineListener(); } } /** Subscribe to a frequency */ add(frequency?: number) { if (frequency === undefined) return; if (this.frequencyHistogram.has(frequency)) { this.frequencyHistogram.set( frequency, (this.frequencyHistogram.get(frequency) as number) + 1, ); } else { this.frequencyHistogram.set(frequency, 1); // new min so restart service if (frequency < this.frequency) { this.frequency = frequency; this.run(); } } } /** Unsubscribe from a frequency */ remove(frequency?: number) { if (frequency === undefined) return false; if (this.frequencyHistogram.has(frequency)) { this.frequencyHistogram.set( frequency, (this.frequencyHistogram.get(frequency) as number) - 1, ); if ((this.frequencyHistogram.get(frequency) as number) < 1) { this.frequencyHistogram.delete(frequency); // nothing subscribed to this anymore...it is invalid if (this.frequencyHistogram.size === 0) { this.cleanup(); return true; } // this was the min, so find the next size if (frequency <= this.frequency) { this.frequency = Math.min(...this.frequencyHistogram.keys()); this.run(); } } } /* istanbul ignore next */ else if ( process.env.NODE_ENV !== 'production' ) { console.error( `Mismatched remove: ${frequency} is not subscribed for ${this.key}`, ); } return false; } /** Cleanup means clearing out background interval. */ cleanup() { if (this.intervalId) { clearInterval(this.intervalId); delete this.intervalId; } if (this.lastIntervalId) { clearInterval(this.lastIntervalId); delete this.lastIntervalId; } if (this.startId) { clearTimeout(this.startId); delete this.startId; } this.connectionListener.removeOnlineListener(this.onlineListener); this.connectionListener.removeOfflineListener(this.offlineListener); } /** Trigger request for latest resource */ protected update() { const sup = this.endpoint; const endpoint = function (this: any, ...args: any[]) { return sup.call(this, ...args); }; Object.assign(endpoint, this.endpoint); endpoint.dataExpiryLength = this.frequency / 2; endpoint.errorExpiryLength = this.frequency / 10; endpoint.errorPolicy = () => 'soft' as const; endpoint.key = () => this.key; // stop any errors here from bubbling this.controller.fetch(endpoint, ...this.args).catch(() => null); } /** What happens when browser goes offline */ protected offlineListener = () => { // this clears existing listeners, so no need to clear offline listener this.cleanup(); this.connectionListener.addOnlineListener(this.onlineListener); }; /** What happens when browser comes online */ protected onlineListener = () => { this.connectionListener.removeOnlineListener(this.onlineListener); const now = Date.now(); this.startId = setTimeout( () => { if (this.startId) { delete this.startId; this.update(); this.run(); } else if (process.env.NODE_ENV !== 'production') { console.warn( `Poll setTimeout for ${this.key} still running, but timeoutId deleted`, ); } }, Math.max(0, this.lastFetchTime() - now + this.frequency), ); this.connectionListener.addOfflineListener(this.offlineListener); }; /** Run polling process with current frequency * * Will clean up old poll interval on next run */ protected run() { if (this.startId) return; if (this.intervalId) this.lastIntervalId = this.intervalId; this.intervalId = setInterval(() => { // since we don't know how long into the last poll it was before resetting // we wait til the next fetch to clear old intervals if (this.lastIntervalId) { clearInterval(this.lastIntervalId); delete this.lastIntervalId; } if (this.intervalId) this.update(); else if (process.env.NODE_ENV !== 'production') { console.warn( `Poll intervalId for ${this.key} still running, but intervalId deleted`, ); } }, this.frequency); } /** Last fetch time */ protected lastFetchTime() { return this.controller.getState().meta[this.key]?.date ?? 0; } }