diff --git a/.changeset/green-nights-report.md b/.changeset/green-nights-report.md new file mode 100644 index 000000000..35469e7d1 --- /dev/null +++ b/.changeset/green-nights-report.md @@ -0,0 +1,5 @@ +--- +'magicbell-js': minor +--- + +add 'socket' client for realtime notification listening diff --git a/packages/magicbell-js/.gitignore b/packages/magicbell-js/.gitignore index c242625de..9ae5759da 100644 --- a/packages/magicbell-js/.gitignore +++ b/packages/magicbell-js/.gitignore @@ -1,4 +1,5 @@ docs-dist out /project-client/package.json +/socket/package.json /user-client/package.json diff --git a/packages/magicbell-js/package.json b/packages/magicbell-js/package.json index 601469bec..5be620679 100644 --- a/packages/magicbell-js/package.json +++ b/packages/magicbell-js/package.json @@ -26,6 +26,16 @@ "default": "./dist/commonjs/project-client.js" } }, + "./socket": { + "import": { + "types": "./dist/esm/socket.d.ts", + "default": "./dist/esm/socket.js" + }, + "require": { + "types": "./dist/commonjs/socket.d.ts", + "default": "./dist/commonjs/socket.js" + } + }, "./user-client": { "import": { "types": "./dist/esm/user-client.d.ts", @@ -43,7 +53,8 @@ "src", "README.md", "/project-client/package.json", - "/user-client/package.json" + "/user-client/package.json", + "/socket/package.json" ], "scripts": { "build": "run-s build:bundle build:node10 build:docs build:attw", diff --git a/packages/magicbell-js/src/socket.ts b/packages/magicbell-js/src/socket.ts new file mode 100644 index 000000000..7c1fcecc5 --- /dev/null +++ b/packages/magicbell-js/src/socket.ts @@ -0,0 +1,228 @@ +import invariant from 'tiny-invariant'; + +import { type Notification, Client } from './user-client.js'; + +export class Socket { + #client: Client; + #socketUrl = 'wss://ws.magicbell.com'; + #inboxToken: string | undefined; + #origin: string | undefined; + #websocket: WebSocket | undefined; + #isConnected = false; + #reconnectAttempts = 0; + #maxReconnectAttempts = 5; + #reconnectInterval = 1000; + #notificationHandler: ((notification: Notification) => void) | undefined; + + constructor(options: { token: string } | Client) { + if (options instanceof Client) { + this.#client = options; + } else { + this.#client = new Client({ token: options.token }); + } + } + + async listen(onNotification: (notification: Notification) => void) { + this.#notificationHandler = onNotification; + + if (this.#websocket && this.#isConnected) { + console.warn('Already connected to WebSocket'); + return; + } + + try { + const url = await this.#getUrl(); + this.#websocket = new WebSocket(url); + + this.#websocket.onopen = () => { + this.#isConnected = true; + this.#reconnectAttempts = 0; + this.#reconnectInterval = 1000; + }; + + this.#websocket.onmessage = (event) => { + if (event.origin !== this.#origin) return; + + try { + const data = JSON.parse(event.data); + this.#handleMessage(data); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + this.#websocket.onclose = (event) => { + console.warn('WebSocket disconnected:', event.code, event.reason); + this.#isConnected = false; + this.#handleReconnect(); + }; + + this.#websocket.onerror = (error) => { + console.error('WebSocket error:', error); + this.#isConnected = false; + }; + } catch (error) { + console.error('Failed to connect to WebSocket:', error); + this.#handleReconnect(); + } + } + + disconnect() { + if (this.#websocket) { + this.#websocket.close(); + this.#websocket = undefined; + } + this.#isConnected = false; + this.#reconnectAttempts = 0; + } + + isListening() { + return this.#isConnected; + } + + #handleMessage(data: any) { + if (!this.#isNewNotificationMessage(data)) { + return; + } + + this.#handleNewNotification(data.data.id); + } + + #isNewNotificationMessage(data: any): data is { name: 'notifications/new'; data: { id: string } } { + return ( + typeof data === 'object' && + data !== null && + data.name === 'notifications/new' && + typeof data.data === 'object' && + data.data !== null && + typeof data.data.id === 'string' + ); + } + + async #handleNewNotification(notificationId: string) { + if (!this.#notificationHandler) { + console.warn('No notification handler provided'); + return; + } + + try { + const { data: notification, metadata: res } = await this.#client.notifications.fetchNotification(notificationId); + + if (!isOK(res)) { + console.error(`Failed to fetch notification ${notificationId}: ${res.status} ${res.statusText}`); + return; + } + + if (notification) { + this.#notificationHandler(notification); + } + } catch (error) { + console.error(`Error fetching notification ${notificationId}:`, error); + } + } + + #handleReconnect() { + if (this.#reconnectAttempts >= this.#maxReconnectAttempts) { + console.error('Max reconnection attempts reached'); + return; + } + + if (!this.#notificationHandler) { + console.warn('No notification handler, skipping reconnect'); + return; + } + + setTimeout(() => { + this.#reconnectAttempts++; + this.#reconnectInterval = Math.min(this.#reconnectInterval * 2, 30000); // Max 30 seconds + this.listen(this.#notificationHandler!); + }, this.#reconnectInterval); + } + + async #getUrl() { + const jwtToken = this.#client.config.token; + invariant(jwtToken, 'Failed to get token from client'); + const apiKey = getApiKeyFromToken(jwtToken); + invariant(apiKey, 'Failed to get API key from token'); + + const token = await this.#getToken(); + const url = new URL(this.#socketUrl); + url.searchParams.set('api_key', apiKey); + url.searchParams.set('token', token); + + this.#origin = url.origin; + return url.toString(); + } + + async #getToken() { + if (this.#inboxToken) return this.#inboxToken; + + const { data, metadata: res } = await this.#client.channels.saveInboxToken({ + token: getSessionId(), + }); + + invariant(isOK(res), `Failed to save Inbox token: ${res.status} ${res.statusText}`); + invariant(data?.token, 'Unexpected server response, missing token'); + + this.#inboxToken = data.token; + return this.#inboxToken; + } +} + +function isOK(response: { status: number }) { + return response.status >= 200 && response.status < 300; +} + +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; +} + +function generateID(length = 17) { + let id = ''; + + while (id.length < length) { + id += getRandomValues(); + } + + return id.substring(0, length); +} + +function getApiKeyFromToken(token: string) { + const data = getTokenPayload(token); + if (!data) return null; + + if (data.api_key) { + return data.api_key; + } + + return null; +} + +function getTokenPayload(token: string) { + try { + const [_, payload] = token.split('.'); + const data = JSON.parse(atob(payload)); + return data || null; + } catch { + return null; + } +} + +const getRandomValues = + typeof crypto !== 'undefined' && crypto.getRandomValues + ? () => crypto.getRandomValues(new Uint32Array(1))[0].toString(36) + : () => Math.random().toString(36).substring(2, 15);