magicbell
Version:
MagicBell API wrapper
188 lines • 6.85 kB
JavaScript
import '../lib/signal-polyfill.js';
import WebSocket from 'reconnecting-websocket';
import * as log from '../client/log.js';
import { ASYNC_ITERATOR_SYMBOL, makeForEach } from '../client/paginate.js';
import { generateID } from '../lib/crypto.js';
function getWebSocket() {
if (typeof WebSocket === 'function') {
return WebSocket;
}
const moduleDefault = WebSocket.default;
if (typeof moduleDefault === 'function') {
return moduleDefault;
}
throw Error('WebSocket is not defined');
}
export function getSessionId() {
if (typeof sessionStorage === 'undefined') {
return generateID(64);
}
// sessionStorage gets cleared when the page session ends. A page
// session lasts for as long as the browser is open and survives
// over page reloads and restores. Opening a page in a new tab or
// window will cause a new session to be initiated. This gives us
// a stable ID per tab, and different ID's across tabs.
const stored = sessionStorage.getItem('magicbell--realtime-token');
if (stored)
return stored;
const id = generateID(64);
sessionStorage.setItem('magicbell--realtime-token', id);
return id;
}
export function createListener(client, options) {
let socket = null;
let origin = null;
const socketURL = options.socketURL || 'wss://ws.magicbell.com';
const messages = [];
let resolve;
let activeCount = 0;
let reconnecting = false;
const pushMessage = (p) => {
messages.push(p);
if (resolve) {
resolve();
resolve = null;
}
};
async function getToken(requestOptions) {
const data = { inbox: { token: getSessionId() } };
const res = await client.request({ method: 'POST', path: '/channels/in_app/inbox/tokens', data }, requestOptions);
return res['in_app/inbox'].token;
}
async function getUrl(requestOptions) {
const token = await getToken(requestOptions);
const url = new URL(socketURL);
url.searchParams.set('api_key', options.apiKey);
url.searchParams.set('token', token);
origin = url.origin;
return url.toString();
}
// accept callback or yield
async function connect(requestOptions) {
if (activeCount < 1)
return;
const WS = getWebSocket();
if (socket && socket.readyState !== WS.CLOSED) {
return;
}
// new connection, flush all messages so we don't auto close this immediately
// because of stuck { done: true } messages in React.StrictMode
messages.length = 0;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
socket = new WS(() => getUrl(requestOptions));
socket.addEventListener('open', () => {
log.info('socket: open');
});
const handleError = (err) => {
const isTokenErr = err?.code >= 40140 && err.code < 40150;
if (isTokenErr) {
reconnecting = true;
socket?.close();
socket = null;
void connect(requestOptions);
return;
}
if (/invalid channel id/i.test(err?.message)) {
socket?.close();
socket = null;
pushMessage({ value: null, done: true });
return;
}
log.error('socket: error', err);
};
socket.addEventListener('message', (event) => {
if (event.origin !== origin)
return;
if (!('data' in event))
return;
if (event.type !== 'message')
return;
try {
const data = JSON.parse(event.data);
if (data.type === 'error') {
handleError(data);
return;
}
if (data.type === 'close') {
return pushMessage({ value: null, done: true });
}
log.debug('socket: received message', data);
pushMessage({ value: data, done: false });
}
catch (e) {
log.error('socket: failed to parse message', {
data: event.data,
error: e,
});
}
});
socket.addEventListener('close', () => {
log.info('socket: close');
if (reconnecting) {
reconnecting = false;
return;
}
return pushMessage({ value: null, done: true });
});
// handle connection errors
socket.addEventListener('error', (event) => {
if (!('data' in event)) {
log.error('socket: error', event);
return;
}
try {
const err = JSON.parse(event.data);
handleError(err);
}
catch (error) {
log.error('socket: error', { error, event });
}
});
}
function listen(options) {
activeCount++;
void connect(options);
const asyncIteratorNext = async () => {
let event = null;
while (!event) {
if (!messages.length)
await new Promise((r) => (resolve = r));
event = messages.pop();
}
// It's weird that `event` can be undefined? We don't push empty messages
// This happens when running in <React.StrictMode />. I guess there are
// two instances resolving the promise above, the second resolve results
// in no content. See this github pr for more context:
// https://github.com/magicbell/magicbell-js/pull/189
if (!event)
return { done: false, value: '' };
if (event.done && socket)
socket.close();
return event;
};
const dispose = () => {
activeCount--;
if (activeCount < 1) {
socket?.close();
socket = null;
}
// push to resolve async iterators, return for sync ones
pushMessage({ done: true, value: undefined });
return { done: true, value: undefined };
};
const forEach = makeForEach(asyncIteratorNext, dispose);
const autoPaginationMethods = {
forEach,
close: () => void dispose(),
next: asyncIteratorNext,
return: dispose,
[ASYNC_ITERATOR_SYMBOL]: () => {
return autoPaginationMethods;
},
};
return autoPaginationMethods;
}
return listen;
}
//# sourceMappingURL=listen.js.map