Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-nights-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'magicbell-js': minor
---

add 'socket' client for realtime notification listening
1 change: 1 addition & 0 deletions packages/magicbell-js/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
docs-dist
out
/project-client/package.json
/socket/package.json
/user-client/package.json
13 changes: 12 additions & 1 deletion packages/magicbell-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
228 changes: 228 additions & 0 deletions packages/magicbell-js/src/socket.ts
Original file line number Diff line number Diff line change
@@ -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);