@exoquic/sub
Version:
Exoquic subscriber API
273 lines (225 loc) • 8.42 kB
JavaScript
import { deleteDB, openDB } from "idb";
import { jwtDecode } from "jwt-decode";
import { v4 } from "uuid";
export class SubscriptionManager {
/**
* @param {function} fetcher a function that fetches the authorized subscriptiom token from your backend
* @param {string} env the environment to use. "dev" for development or "prod" for production
* @param {string} name the name of the subscription manager. An IndexedDB database will be created with this name.
*/
constructor(fetcher = () => { throw new Error("Missing fetcher function") }, { env = "dev", subscribeUrl, name = "default", cacheEnabled = true }) {
this.fetcher = fetcher;
if (!subscribeUrl) {
this.subscribeUrl = `https://${env}.exoquic.com/subscribe`
} else {
this.subscribeUrl = subscribeUrl
}
this.cacheEnabled = cacheEnabled;
this.name = name;
this.db = null;
this.tabId = v4();
if (typeof window !== "undefined") {
window.addEventListener("focus", () => {
localStorage.setItem(`${this.name}-tabId`, this.tabId);
});
}
(async (name, cacheEnabled) => {
if (cacheEnabled) {
this.db = await openDB(name, 1, {
upgrade(db) {
// Table for storing subscription token(singular).
db.createObjectStore("subscriptionTokens", { autoIncrement: true });
// Table for storing subscribers.
db.createObjectStore("subscribersMetadata", { autoIncrement: true });
}
});
}
})(this.name, this.cacheEnabled);
}
async authorizeSubscriber(subscriptionData = null, cacheEnabled = this.cacheEnabled) {
if (!this.cacheEnabled || !cacheEnabled) {
const subscriptionToken = await this.fetcher(subscriptionData);
const decodedToken = jwtDecode(subscriptionToken);
return new AuthorizedSubscriber(
subscriptionToken,
{ ...DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS, serverUrl: this.subscribeUrl },
null,
this.fetcher,
this.cacheEnabled,
decodedToken,
this.name,
null,
this.tabId
);
}
let subscriptionToken;
let subscriberKey = JSON.stringify(subscriptionData);
const cachedSubscriptionToken = await this.db.get("subscriptionTokens", subscriberKey);
if (!cachedSubscriptionToken) {
// Cache subscriptionToken if it isn't cached
subscriptionToken = await this.fetcher(subscriptionData);
await this.db.put("subscriptionTokens", subscriptionToken, subscriberKey);
} else {
// Use the cached subscription token
subscriptionToken = cachedSubscriptionToken;
}
let decodedToken = jwtDecode(subscriptionToken);
let subscribersMetadata = await this.db.getAll("subscribersMetadata");
if (decodedToken.exp < Math.floor(Date.now() / 1000)) {
// If the token is expired, fetch a new one and update the cache.
subscriptionToken = await this.fetcher(subscriptionData);
await this.db.put("subscriptionTokens", subscriptionToken, subscriberKey);
// Clear all the cached subscriber data.
const subscriberMetadata = subscribersMetadata.find(subscriberMetadata => subscriberMetadata.name === subscriberKey);
if (subscriberMetadata) {
const subscriberDb = await openDB(subscriberMetadata.name, 1);
await subscriberDb.clear(subscriberMetadata.name);
subscriberDb.close();
}
decodedToken = jwtDecode(subscriptionToken);
}
if (!subscriptionToken) {
throw new Error("Cannot authorize subscriber because fetcher function returned null");
}
if (typeof subscriptionToken !== 'string') {
throw new Error(`Cannot authorize subscriber because fetcher function returned a non-string value: ${subscriptionToken}`);
}
let subscriberMetadata = subscribersMetadata.find(subscriberMetadata => subscriberMetadata.name === subscriberKey);
if (!subscriberMetadata) {
// Cache subscriber metadata if it isn't cached
subscriberMetadata = { name: subscriberKey }
await this.db.put("subscribersMetadata", subscriberMetadata);
}
return new AuthorizedSubscriber(subscriptionToken,
{ ...DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS, serverUrl: this.subscribeUrl },
this.db,
this.fetcher,
this.cacheEnabled,
decodedToken,
this.name,
subscriberMetadata,
this.tabId
);
}
}
export const DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS = {
shouldReconnect: true,
reconnectTimeout: 1000,
maxReconnectTimeout: 10000,
serverUrl: 'wss://dev.exoquic.com/subscribe',
};
export class AuthorizedSubscriber {
constructor(
authorizationToken,
{
serverUrl = DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS.serverUrl,
shouldReconnect = DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS.shouldReconnect,
reconnectTimeout = DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS.reconnectTimeout,
maxReconnectTimeout = DEFAULT_AUTHORIZED_SUBSCRIPTION_SETTINGS.maxReconnectTimeout
},
db,
fetcherFunc,
cacheEnabled,
decodedToken,
subscriptionManagerName,
subscriberMetadata,
tabId
) {
this.authorizationToken = authorizationToken;
this.serverUrl = serverUrl;
this.shouldReconnect = shouldReconnect;
this.reconnectTimeout = reconnectTimeout;
this.maxReconnectTimeout = maxReconnectTimeout;
this.isSubscribed = false;
this.isConnected = false;
this.db = db;
this.fetcherFunc = fetcherFunc;
this.cacheEnabled = cacheEnabled;
this.decodedToken = decodedToken;
this.name = subscriptionManagerName;
this.subscriberMetadata = subscriberMetadata;
this.tabId = tabId;
}
/**
* @param {function} onMessageCallback a function that is called when a new event batch is received
* @returns {AuthorizedSubscriber} the authorized subscriber
*/
subscribe(onEventBatchReceivedCallback) {
if (this.isSubscribed) {
return;
}
this.ws = new WebSocket(`${this.serverUrl}`, [this.authorizationToken]);
this.isSubscribed = true;
if (this.cacheEnabled) {
(async () => {
const subscriberDb = await openDB(this.subscriberMetadata.name, 1, {
upgrade(db) {
db.createObjectStore(db.name, { autoIncrement: true });
}
});
localStorage.setItem(`${this.name}-tabId`, this.tabId);
const events = await (subscriberDb.transaction(this.subscriberMetadata.name, 'readwrite').objectStore(this.subscriberMetadata.name).getAll());
events.forEach(eventBatch => {
onEventBatchReceivedCallback(eventBatch);
});
this.ws.onmessage = eventBatchRawJson => {
const parsedEventBatch = JSON.parse(eventBatchRawJson.data);
if (parsedEventBatch.length <= 0) {
return;
}
onEventBatchReceivedCallback(parsedEventBatch);
if (localStorage.getItem(`${this.name}-tabId`) == this.tabId) {
console.log(`[${this.subscriberMetadata.name}] storing ${parsedEventBatch}`);
subscriberDb.transaction(this.subscriberMetadata.name, 'readwrite').objectStore(this.subscriberMetadata.name).add(parsedEventBatch);
console.log(`[${this.subscriberMetadata.name}] stored ${parsedEventBatch}`);
}
};
this.ws.onopen = () => {
this.isConnected = true;
};
this.ws.onclose = (event) => {
this.isConnected = false;
const isCleanClose = event.code === 1000;
if (this.shouldReconnect && !isCleanClose) {
setTimeout(() => {
this.reconnectTimeout = Math.min(this.reconnectTimeout * 1.5, this.maxReconnectTimeout);
this.subscribe(onEventBatchReceivedCallback);
}, this.reconnectTimeout);
}
if (!this.shouldReconnect) {
subscriberDb.close();
}
};
this.ws.onerror = (error) => {
console.error('Exoquic subscription error:', error);
};
})();
} else {
this.ws.onmessage = eventBatchRawJson => {
const parsedEventBatch = JSON.parse(eventBatchRawJson.data);
onEventBatchReceivedCallback(parsedEventBatch);
};
this.ws.onopen = () => {
this.isConnected = true;
};
this.ws.onclose = (event) => {
this.isConnected = false;
const isCleanClose = event.code === 1000;
if (this.shouldReconnect && !isCleanClose) {
setTimeout(() => {
this.reconnectTimeout = Math.min(this.reconnectTimeout * 1.5, this.maxReconnectTimeout);
this.subscribe(onEventBatchReceivedCallback);
}, this.reconnectTimeout);
}
};
this.ws.onerror = (error) => {
console.error('Exoquic subscription error:', error);
};
}
return this;
}
unsubscribe() {
this.isSubscribed = false;
this.ws.close(1000, "unsubscribe");
}
}