Implement IronNotify JavaScript SDK
- NotifyClient with simple notify() and event builder APIs - Real-time WebSocket support with auto-reconnect - Offline queue with localStorage persistence - Event builder with fluent API - Notification management (get, markAsRead, unreadCount) - Full TypeScript types - ESM + CommonJS dual publishing
This commit is contained in:
parent
de08d57638
commit
bd63d195ee
|
|
@ -0,0 +1,26 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
333
README.md
333
README.md
|
|
@ -1,2 +1,331 @@
|
|||
# ironnotify-js
|
||||
IronNotify SDK for JavaScript/TypeScript - Event notifications and alerts
|
||||
# IronNotify SDK for JavaScript/TypeScript
|
||||
|
||||
Event notifications and alerts SDK for JavaScript/TypeScript applications. Send notifications, receive real-time updates, and manage notification state.
|
||||
|
||||
[](https://www.npmjs.com/package/@ironservices/notify)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @ironservices/notify
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Send a Simple Notification
|
||||
|
||||
```typescript
|
||||
import { NotifyClient } from '@ironservices/notify';
|
||||
|
||||
const client = new NotifyClient({
|
||||
apiKey: 'ak_live_xxxxx',
|
||||
});
|
||||
|
||||
// Send a simple notification
|
||||
await client.notify('order.created', 'New Order Received', {
|
||||
message: 'Order #1234 has been placed',
|
||||
severity: 'success',
|
||||
metadata: { orderId: '1234', amount: 99.99 },
|
||||
});
|
||||
```
|
||||
|
||||
### Fluent Event Builder
|
||||
|
||||
```typescript
|
||||
import { NotifyClient } from '@ironservices/notify';
|
||||
|
||||
const client = new NotifyClient({ apiKey: 'ak_live_xxxxx' });
|
||||
|
||||
// Build complex notifications with the fluent API
|
||||
await client.event('payment.failed')
|
||||
.withTitle('Payment Failed')
|
||||
.withMessage('The payment for order #1234 could not be processed')
|
||||
.withSeverity('error')
|
||||
.withMetadata({ orderId: '1234', reason: 'Card declined' })
|
||||
.withAction({ label: 'Retry Payment', url: '/orders/1234/retry', style: 'primary' })
|
||||
.withAction({ label: 'Contact Support', action: 'open_support' })
|
||||
.forUser('user-123')
|
||||
.withDeduplicationKey('payment-failed-1234')
|
||||
.expiresIn(24 * 60 * 60 * 1000) // 24 hours
|
||||
.send();
|
||||
```
|
||||
|
||||
### Real-Time Notifications
|
||||
|
||||
```typescript
|
||||
import { NotifyClient } from '@ironservices/notify';
|
||||
|
||||
const client = new NotifyClient({ apiKey: 'ak_live_xxxxx' });
|
||||
|
||||
// Set up handlers
|
||||
client.onNotification((notification) => {
|
||||
console.log('New notification:', notification.title);
|
||||
showToast(notification);
|
||||
});
|
||||
|
||||
client.onUnreadCountChange((count) => {
|
||||
updateBadge(count);
|
||||
});
|
||||
|
||||
client.onConnectionStateChange((state) => {
|
||||
console.log('Connection state:', state);
|
||||
});
|
||||
|
||||
// Connect and subscribe
|
||||
client.connect();
|
||||
client.subscribeToUser('user-123');
|
||||
client.subscribeToApp(); // App-wide notifications
|
||||
```
|
||||
|
||||
### Global Client (Convenience API)
|
||||
|
||||
```typescript
|
||||
import * as ironnotify from '@ironservices/notify';
|
||||
|
||||
// Initialize once
|
||||
ironnotify.init({ apiKey: 'ak_live_xxxxx' });
|
||||
|
||||
// Use anywhere
|
||||
await ironnotify.notify('user.signup', 'New User Registered');
|
||||
|
||||
// Connect to real-time
|
||||
ironnotify.connect();
|
||||
ironnotify.subscribeToUser('user-123');
|
||||
ironnotify.onNotification((n) => console.log(n));
|
||||
|
||||
// Clean up
|
||||
ironnotify.close();
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
import { NotifyClient } from '@ironservices/notify';
|
||||
|
||||
const client = new NotifyClient({
|
||||
apiKey: 'ak_live_xxxxx',
|
||||
apiBaseUrl: 'https://api.ironnotify.com',
|
||||
wsUrl: 'wss://ws.ironnotify.com',
|
||||
debug: false,
|
||||
enableOfflineQueue: true,
|
||||
maxOfflineQueueSize: 100,
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `apiKey` | string | required | Your API key (ak_live_xxx or ak_test_xxx) |
|
||||
| `apiBaseUrl` | string | 'https://api.ironnotify.com' | API base URL |
|
||||
| `wsUrl` | string | 'wss://ws.ironnotify.com' | WebSocket URL |
|
||||
| `debug` | boolean | false | Enable debug logging |
|
||||
| `enableOfflineQueue` | boolean | true | Queue notifications when offline |
|
||||
| `maxOfflineQueueSize` | number | 100 | Max offline queue size |
|
||||
| `autoReconnect` | boolean | true | Auto-reconnect WebSocket |
|
||||
| `maxReconnectAttempts` | number | 5 | Max reconnection attempts |
|
||||
| `reconnectDelay` | number | 1000 | Base reconnection delay (ms) |
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple Notifications**: Send notifications with one line of code
|
||||
- **Fluent Builder**: Build complex notifications with an intuitive API
|
||||
- **Real-Time Updates**: Receive notifications instantly via WebSocket
|
||||
- **Offline Queue**: Notifications are queued when offline and sent when back online
|
||||
- **Auto-Reconnect**: Automatic WebSocket reconnection with exponential backoff
|
||||
- **Type Safety**: Full TypeScript support with comprehensive type definitions
|
||||
- **Universal**: Works in Node.js and browsers
|
||||
|
||||
## Notification Options
|
||||
|
||||
### Severity Levels
|
||||
|
||||
```typescript
|
||||
type SeverityLevel = 'info' | 'success' | 'warning' | 'error' | 'critical';
|
||||
|
||||
await client.notify('alert', 'System Alert', { severity: 'critical' });
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
```typescript
|
||||
await client.event('order.shipped')
|
||||
.withTitle('Order Shipped')
|
||||
.withAction({
|
||||
label: 'Track Package',
|
||||
url: 'https://tracking.example.com/123',
|
||||
style: 'primary',
|
||||
})
|
||||
.withAction({
|
||||
label: 'View Order',
|
||||
action: 'view_order', // Custom action identifier
|
||||
style: 'default',
|
||||
})
|
||||
.send();
|
||||
```
|
||||
|
||||
### Deduplication
|
||||
|
||||
Prevent duplicate notifications:
|
||||
|
||||
```typescript
|
||||
await client.event('reminder')
|
||||
.withTitle('Daily Reminder')
|
||||
.withDeduplicationKey('daily-reminder-2024-01-15')
|
||||
.send();
|
||||
```
|
||||
|
||||
### Grouping
|
||||
|
||||
Group related notifications:
|
||||
|
||||
```typescript
|
||||
await client.event('comment.new')
|
||||
.withTitle('New Comment')
|
||||
.withGroupKey('post-123-comments')
|
||||
.send();
|
||||
```
|
||||
|
||||
### Expiration
|
||||
|
||||
Set notification expiration:
|
||||
|
||||
```typescript
|
||||
// Expires in 1 hour
|
||||
await client.event('flash_sale')
|
||||
.withTitle('Flash Sale!')
|
||||
.expiresIn(60 * 60 * 1000)
|
||||
.send();
|
||||
|
||||
// Expires at specific time
|
||||
await client.event('event_reminder')
|
||||
.withTitle('Event Tomorrow')
|
||||
.expiresOn(new Date('2024-01-16T09:00:00Z'))
|
||||
.send();
|
||||
```
|
||||
|
||||
## Managing Notifications
|
||||
|
||||
### Get Notifications
|
||||
|
||||
```typescript
|
||||
// Get all notifications
|
||||
const notifications = await client.getNotifications();
|
||||
|
||||
// With options
|
||||
const unread = await client.getNotifications({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
unreadOnly: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Mark as Read
|
||||
|
||||
```typescript
|
||||
// Mark single notification
|
||||
await client.markAsRead('notification-id');
|
||||
|
||||
// Mark all as read
|
||||
await client.markAllAsRead();
|
||||
```
|
||||
|
||||
### Get Unread Count
|
||||
|
||||
```typescript
|
||||
const count = await client.getUnreadCount();
|
||||
console.log(`You have ${count} unread notifications`);
|
||||
```
|
||||
|
||||
## WebSocket Connection States
|
||||
|
||||
```typescript
|
||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
||||
|
||||
client.onConnectionStateChange((state) => {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
console.log('Connected to real-time notifications');
|
||||
break;
|
||||
case 'reconnecting':
|
||||
console.log('Reconnecting...');
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('Disconnected');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Check current state
|
||||
console.log(client.connectionState);
|
||||
```
|
||||
|
||||
## Offline Support
|
||||
|
||||
Notifications are automatically queued when offline:
|
||||
|
||||
```typescript
|
||||
const client = new NotifyClient({
|
||||
apiKey: 'ak_live_xxxxx',
|
||||
enableOfflineQueue: true,
|
||||
maxOfflineQueueSize: 100,
|
||||
});
|
||||
|
||||
// This will be queued if offline
|
||||
await client.notify('event', 'Title');
|
||||
|
||||
// Manually flush the queue
|
||||
await client.flush();
|
||||
```
|
||||
|
||||
## React Example
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NotifyClient, Notification } from '@ironservices/notify';
|
||||
|
||||
function useNotifications(apiKey: string, userId: string) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new NotifyClient({ apiKey });
|
||||
|
||||
client.onNotification((notification) => {
|
||||
setNotifications(prev => [notification, ...prev]);
|
||||
});
|
||||
|
||||
client.onUnreadCountChange(setUnreadCount);
|
||||
|
||||
client.connect();
|
||||
client.subscribeToUser(userId);
|
||||
|
||||
// Load initial notifications
|
||||
client.getNotifications().then(setNotifications);
|
||||
client.getUnreadCount().then(setUnreadCount);
|
||||
|
||||
return () => client.close();
|
||||
}, [apiKey, userId]);
|
||||
|
||||
return { notifications, unreadCount };
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 16+ or modern browser
|
||||
- Fetch API (native in modern environments)
|
||||
- WebSocket API (for real-time features)
|
||||
|
||||
## Links
|
||||
|
||||
- [Documentation](https://www.ironnotify.com/docs)
|
||||
- [Dashboard](https://www.ironnotify.com)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "@ironservices/notify",
|
||||
"version": "0.1.0",
|
||||
"description": "IronNotify SDK for JavaScript/TypeScript - Event notifications and alerts",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/cjs/index.js",
|
||||
"types": "./dist/types/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:esm && npm run build:cjs && npm run build:types",
|
||||
"build:esm": "tsc -p tsconfig.esm.json",
|
||||
"build:cjs": "tsc -p tsconfig.cjs.json",
|
||||
"build:types": "tsc -p tsconfig.types.json",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"ironnotify",
|
||||
"notifications",
|
||||
"alerts",
|
||||
"events",
|
||||
"real-time",
|
||||
"websocket"
|
||||
],
|
||||
"author": "IronServices",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/IronServices/ironnotify-js.git"
|
||||
},
|
||||
"homepage": "https://www.ironnotify.com",
|
||||
"bugs": {
|
||||
"url": "https://github.com/IronServices/ironnotify-js/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import type {
|
||||
SeverityLevel,
|
||||
NotificationAction,
|
||||
NotificationPayload,
|
||||
SendResult,
|
||||
} from './types';
|
||||
import type { NotifyClient } from './client';
|
||||
|
||||
/**
|
||||
* Builder for creating notifications with a fluent API.
|
||||
*/
|
||||
export class EventBuilder {
|
||||
private readonly client: NotifyClient;
|
||||
private readonly eventType: string;
|
||||
private title: string = '';
|
||||
private message?: string;
|
||||
private severity: SeverityLevel = 'info';
|
||||
private metadata: Record<string, unknown> = {};
|
||||
private actions: NotificationAction[] = [];
|
||||
private userId?: string;
|
||||
private groupKey?: string;
|
||||
private deduplicationKey?: string;
|
||||
private expiresAt?: Date;
|
||||
|
||||
constructor(client: NotifyClient, eventType: string) {
|
||||
this.client = client;
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the notification title.
|
||||
*/
|
||||
withTitle(title: string): this {
|
||||
this.title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the notification message.
|
||||
*/
|
||||
withMessage(message: string): this {
|
||||
this.message = message;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the severity level.
|
||||
*/
|
||||
withSeverity(severity: SeverityLevel): this {
|
||||
this.severity = severity;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata for the notification.
|
||||
*/
|
||||
withMetadata(metadata: Record<string, unknown>): this {
|
||||
this.metadata = { ...this.metadata, ...metadata };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an action button to the notification.
|
||||
*/
|
||||
withAction(action: NotificationAction): this {
|
||||
this.actions.push(action);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the target user ID.
|
||||
*/
|
||||
forUser(userId: string): this {
|
||||
this.userId = userId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the group key for grouping related notifications.
|
||||
*/
|
||||
withGroupKey(groupKey: string): this {
|
||||
this.groupKey = groupKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the deduplication key to prevent duplicate notifications.
|
||||
*/
|
||||
withDeduplicationKey(key: string): this {
|
||||
this.deduplicationKey = key;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the expiration time for the notification.
|
||||
*/
|
||||
expiresIn(ms: number): this {
|
||||
this.expiresAt = new Date(Date.now() + ms);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the expiration date for the notification.
|
||||
*/
|
||||
expiresOn(date: Date): this {
|
||||
this.expiresAt = date;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notification payload.
|
||||
*/
|
||||
build(): NotificationPayload {
|
||||
if (!this.title) {
|
||||
throw new Error('Notification title is required');
|
||||
}
|
||||
|
||||
return {
|
||||
eventType: this.eventType,
|
||||
title: this.title,
|
||||
message: this.message,
|
||||
severity: this.severity,
|
||||
metadata: Object.keys(this.metadata).length > 0 ? this.metadata : undefined,
|
||||
actions: this.actions.length > 0 ? this.actions : undefined,
|
||||
userId: this.userId,
|
||||
groupKey: this.groupKey,
|
||||
deduplicationKey: this.deduplicationKey,
|
||||
expiresAt: this.expiresAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the notification.
|
||||
*/
|
||||
async send(): Promise<SendResult> {
|
||||
const payload = this.build();
|
||||
return this.client.sendPayload(payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import type {
|
||||
NotifyOptions,
|
||||
NotificationPayload,
|
||||
Notification,
|
||||
SendResult,
|
||||
SeverityLevel,
|
||||
ConnectionState,
|
||||
NotifyEventHandlers,
|
||||
} from './types';
|
||||
import { parseApiKey, resolveOptions } from './config';
|
||||
import { Transport } from './transport';
|
||||
import { OfflineQueue } from './queue';
|
||||
import { WebSocketManager } from './websocket';
|
||||
import { EventBuilder } from './builder';
|
||||
|
||||
/**
|
||||
* IronNotify client for sending and receiving notifications.
|
||||
*/
|
||||
export class NotifyClient {
|
||||
private readonly options: Required<NotifyOptions>;
|
||||
private readonly transport: Transport;
|
||||
private readonly queue: OfflineQueue;
|
||||
private readonly wsManager: WebSocketManager;
|
||||
private isOnline = true;
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(options: NotifyOptions) {
|
||||
parseApiKey(options.apiKey); // Validate API key
|
||||
this.options = resolveOptions(options);
|
||||
|
||||
this.transport = new Transport(
|
||||
this.options.apiBaseUrl,
|
||||
this.options.apiKey,
|
||||
this.options.debug
|
||||
);
|
||||
|
||||
this.queue = new OfflineQueue(
|
||||
this.options.maxOfflineQueueSize,
|
||||
this.options.debug
|
||||
);
|
||||
|
||||
this.wsManager = new WebSocketManager(
|
||||
this.options.wsUrl,
|
||||
this.options.apiKey,
|
||||
this.options.debug,
|
||||
this.options.autoReconnect,
|
||||
this.options.maxReconnectAttempts,
|
||||
this.options.reconnectDelay
|
||||
);
|
||||
|
||||
if (this.options.debug) {
|
||||
console.log('[IronNotify] Initialized');
|
||||
}
|
||||
|
||||
// Start periodic flush of offline queue
|
||||
if (this.options.enableOfflineQueue) {
|
||||
this.startQueueFlush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification with simple parameters.
|
||||
*/
|
||||
async notify(
|
||||
eventType: string,
|
||||
title: string,
|
||||
options?: {
|
||||
message?: string;
|
||||
severity?: SeverityLevel;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
}
|
||||
): Promise<SendResult> {
|
||||
const payload: NotificationPayload = {
|
||||
eventType,
|
||||
title,
|
||||
message: options?.message,
|
||||
severity: options?.severity ?? 'info',
|
||||
metadata: options?.metadata,
|
||||
userId: options?.userId,
|
||||
};
|
||||
|
||||
return this.sendPayload(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event builder for complex notifications.
|
||||
*/
|
||||
event(eventType: string): EventBuilder {
|
||||
return new EventBuilder(this, eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification payload.
|
||||
* @internal
|
||||
*/
|
||||
async sendPayload(payload: NotificationPayload): Promise<SendResult> {
|
||||
const result = await this.transport.send(payload);
|
||||
|
||||
if (!result.success && this.options.enableOfflineQueue) {
|
||||
this.queue.add(payload);
|
||||
this.isOnline = false;
|
||||
return { ...result, queued: true };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to real-time notifications.
|
||||
*/
|
||||
connect(): void {
|
||||
this.wsManager.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from real-time notifications.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.wsManager.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a user's notifications.
|
||||
*/
|
||||
subscribeToUser(userId: string): void {
|
||||
this.wsManager.subscribeToUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to app-wide notifications.
|
||||
*/
|
||||
subscribeToApp(): void {
|
||||
this.wsManager.subscribeToApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event handlers for real-time notifications.
|
||||
*/
|
||||
on(handlers: NotifyEventHandlers): void {
|
||||
this.wsManager.setHandlers(handlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set notification handler.
|
||||
*/
|
||||
onNotification(handler: (notification: Notification) => void): void {
|
||||
this.wsManager.setHandlers({ onNotification: handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set unread count change handler.
|
||||
*/
|
||||
onUnreadCountChange(handler: (count: number) => void): void {
|
||||
this.wsManager.setHandlers({ onUnreadCountChange: handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set connection state change handler.
|
||||
*/
|
||||
onConnectionStateChange(handler: (state: ConnectionState) => void): void {
|
||||
this.wsManager.setHandlers({ onConnectionStateChange: handler });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state.
|
||||
*/
|
||||
get connectionState(): ConnectionState {
|
||||
return this.wsManager.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications.
|
||||
*/
|
||||
async getNotifications(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}): Promise<Notification[]> {
|
||||
const result = await this.transport.getNotifications(options);
|
||||
return result?.notifications ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count.
|
||||
*/
|
||||
async getUnreadCount(): Promise<number> {
|
||||
return this.transport.getUnreadCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read.
|
||||
*/
|
||||
async markAsRead(notificationId: string): Promise<boolean> {
|
||||
return this.transport.markAsRead(notificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read.
|
||||
*/
|
||||
async markAllAsRead(): Promise<boolean> {
|
||||
return this.transport.markAllAsRead();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the offline queue.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (this.queue.isEmpty) return;
|
||||
|
||||
const online = await this.transport.isOnline();
|
||||
if (!online) return;
|
||||
|
||||
this.isOnline = true;
|
||||
const notifications = this.queue.getAll();
|
||||
|
||||
for (let i = notifications.length - 1; i >= 0; i--) {
|
||||
const result = await this.transport.send(notifications[i]);
|
||||
if (result.success) {
|
||||
this.queue.remove(i);
|
||||
} else {
|
||||
break; // Stop if we hit an error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the client and clean up resources.
|
||||
*/
|
||||
close(): void {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
this.wsManager.disconnect();
|
||||
}
|
||||
|
||||
private startQueueFlush(): void {
|
||||
this.flushTimer = setInterval(() => {
|
||||
if (!this.isOnline) {
|
||||
this.flush();
|
||||
}
|
||||
}, 30000); // Flush every 30 seconds
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import type { ParsedApiKey, NotifyOptions } from './types';
|
||||
import { defaultOptions } from './types';
|
||||
|
||||
/**
|
||||
* Parse an API key string.
|
||||
* Format: ak_live_xxx or ak_test_xxx
|
||||
*/
|
||||
export function parseApiKey(apiKey: string): ParsedApiKey {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
throw new Error('API key is required');
|
||||
}
|
||||
|
||||
if (!apiKey.startsWith('ak_')) {
|
||||
throw new Error('API key must start with ak_');
|
||||
}
|
||||
|
||||
const isLive = apiKey.startsWith('ak_live_');
|
||||
const isTest = apiKey.startsWith('ak_test_');
|
||||
|
||||
if (!isLive && !isTest) {
|
||||
throw new Error('API key must be ak_live_xxx or ak_test_xxx');
|
||||
}
|
||||
|
||||
return {
|
||||
key: apiKey,
|
||||
isLive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique notification ID.
|
||||
*/
|
||||
export function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for older environments
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve options with defaults.
|
||||
*/
|
||||
export function resolveOptions(opts: NotifyOptions): Required<NotifyOptions> {
|
||||
const defaults = defaultOptions();
|
||||
|
||||
return {
|
||||
apiKey: opts.apiKey,
|
||||
apiBaseUrl: opts.apiBaseUrl ?? defaults.apiBaseUrl!,
|
||||
wsUrl: opts.wsUrl ?? defaults.wsUrl!,
|
||||
debug: opts.debug ?? defaults.debug!,
|
||||
enableOfflineQueue: opts.enableOfflineQueue ?? defaults.enableOfflineQueue!,
|
||||
maxOfflineQueueSize: opts.maxOfflineQueueSize ?? defaults.maxOfflineQueueSize!,
|
||||
autoReconnect: opts.autoReconnect ?? defaults.autoReconnect!,
|
||||
maxReconnectAttempts: opts.maxReconnectAttempts ?? defaults.maxReconnectAttempts!,
|
||||
reconnectDelay: opts.reconnectDelay ?? defaults.reconnectDelay!,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
// Main client export
|
||||
export { NotifyClient } from './client';
|
||||
export { EventBuilder } from './builder';
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
SeverityLevel,
|
||||
NotificationAction,
|
||||
NotificationPayload,
|
||||
Notification,
|
||||
SendResult,
|
||||
NotifyOptions,
|
||||
ConnectionState,
|
||||
NotifyEventHandlers,
|
||||
} from './types';
|
||||
|
||||
// Utility exports
|
||||
export { parseApiKey, generateId, resolveOptions } from './config';
|
||||
export { defaultOptions } from './types';
|
||||
|
||||
// Global client instance
|
||||
import { NotifyClient } from './client';
|
||||
import type { NotifyOptions, SendResult, SeverityLevel, Notification } from './types';
|
||||
|
||||
let globalClient: NotifyClient | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the global NotifyClient.
|
||||
*/
|
||||
export function init(options: NotifyOptions): NotifyClient {
|
||||
globalClient = new NotifyClient(options);
|
||||
return globalClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global NotifyClient.
|
||||
*/
|
||||
export function getClient(): NotifyClient | null {
|
||||
return globalClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification using the global client.
|
||||
*/
|
||||
export async function notify(
|
||||
eventType: string,
|
||||
title: string,
|
||||
options?: {
|
||||
message?: string;
|
||||
severity?: SeverityLevel;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
}
|
||||
): Promise<SendResult> {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
return globalClient.notify(eventType, title, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event builder using the global client.
|
||||
*/
|
||||
export function event(eventType: string) {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
return globalClient.event(eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to real-time notifications using the global client.
|
||||
*/
|
||||
export function connect(): void {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
globalClient.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from real-time notifications using the global client.
|
||||
*/
|
||||
export function disconnect(): void {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
globalClient.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a user's notifications using the global client.
|
||||
*/
|
||||
export function subscribeToUser(userId: string): void {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
globalClient.subscribeToUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to app-wide notifications using the global client.
|
||||
*/
|
||||
export function subscribeToApp(): void {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
globalClient.subscribeToApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set notification handler using the global client.
|
||||
*/
|
||||
export function onNotification(handler: (notification: Notification) => void): void {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
globalClient.onNotification(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications using the global client.
|
||||
*/
|
||||
export async function getNotifications(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}): Promise<Notification[]> {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
return globalClient.getNotifications(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the offline queue using the global client.
|
||||
*/
|
||||
export async function flush(): Promise<void> {
|
||||
if (!globalClient) {
|
||||
throw new Error('IronNotify not initialized. Call init() first.');
|
||||
}
|
||||
return globalClient.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the global client.
|
||||
*/
|
||||
export function close(): void {
|
||||
if (globalClient) {
|
||||
globalClient.close();
|
||||
globalClient = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import type { NotificationPayload } from './types';
|
||||
|
||||
const STORAGE_KEY = 'ironnotify_offline_queue';
|
||||
|
||||
/**
|
||||
* Offline queue for storing notifications when offline.
|
||||
*/
|
||||
export class OfflineQueue {
|
||||
private queue: NotificationPayload[] = [];
|
||||
private readonly maxSize: number;
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(maxSize: number, debug: boolean) {
|
||||
this.maxSize = maxSize;
|
||||
this.debug = debug;
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a notification to the queue.
|
||||
*/
|
||||
add(payload: NotificationPayload): void {
|
||||
if (this.queue.length >= this.maxSize) {
|
||||
this.queue.shift(); // Remove oldest
|
||||
if (this.debug) {
|
||||
console.warn('[IronNotify] Offline queue full, dropping oldest notification');
|
||||
}
|
||||
}
|
||||
|
||||
this.queue.push(payload);
|
||||
this.saveToStorage();
|
||||
|
||||
if (this.debug) {
|
||||
console.log('[IronNotify] Notification queued for later:', payload.eventType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queued notifications.
|
||||
*/
|
||||
getAll(): NotificationPayload[] {
|
||||
return [...this.queue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification from the queue.
|
||||
*/
|
||||
remove(index: number): void {
|
||||
this.queue.splice(index, 1);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the queue.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queue = [];
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue size.
|
||||
*/
|
||||
get size(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if queue is empty.
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return this.queue.length === 0;
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
this.queue = JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
private saveToStorage(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.queue));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import type { NotificationPayload, SendResult } from './types';
|
||||
|
||||
/**
|
||||
* HTTP transport for sending notifications.
|
||||
*/
|
||||
export class Transport {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(apiBaseUrl: string, apiKey: string, debug: boolean) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.apiKey = apiKey;
|
||||
this.debug = debug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the server.
|
||||
*/
|
||||
async send(payload: NotificationPayload): Promise<SendResult> {
|
||||
const url = `${this.apiBaseUrl}/api/v1/notifications`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const error = `HTTP ${response.status}: ${errorText}`;
|
||||
if (this.debug) {
|
||||
console.error('[IronNotify] Failed to send notification:', error);
|
||||
}
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (this.debug) {
|
||||
console.log('[IronNotify] Notification sent:', result.notificationId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
notificationId: result.notificationId,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
if (this.debug) {
|
||||
console.error('[IronNotify] Failed to send notification:', error);
|
||||
}
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications for the current user.
|
||||
*/
|
||||
async getNotifications(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}): Promise<{ notifications: any[]; total: number } | null> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.offset) params.set('offset', String(options.offset));
|
||||
if (options?.unreadOnly) params.set('unreadOnly', 'true');
|
||||
|
||||
const url = `${this.apiBaseUrl}/api/v1/notifications?${params}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read.
|
||||
*/
|
||||
async markAsRead(notificationId: string): Promise<boolean> {
|
||||
const url = `${this.apiBaseUrl}/api/v1/notifications/${notificationId}/read`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read.
|
||||
*/
|
||||
async markAllAsRead(): Promise<boolean> {
|
||||
const url = `${this.apiBaseUrl}/api/v1/notifications/read-all`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count.
|
||||
*/
|
||||
async getUnreadCount(): Promise<number> {
|
||||
const url = `${this.apiBaseUrl}/api/v1/notifications/unread-count`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.count ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server is online.
|
||||
*/
|
||||
async isOnline(): Promise<boolean> {
|
||||
const url = `${this.apiBaseUrl}/api/v1/health`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Severity level for notifications.
|
||||
*/
|
||||
export type SeverityLevel = 'info' | 'success' | 'warning' | 'error' | 'critical';
|
||||
|
||||
/**
|
||||
* Notification action button.
|
||||
*/
|
||||
export interface NotificationAction {
|
||||
label: string;
|
||||
url?: string;
|
||||
action?: string;
|
||||
style?: 'default' | 'primary' | 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification payload sent to the server.
|
||||
*/
|
||||
export interface NotificationPayload {
|
||||
eventType: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
severity?: SeverityLevel;
|
||||
metadata?: Record<string, unknown>;
|
||||
actions?: NotificationAction[];
|
||||
userId?: string;
|
||||
groupKey?: string;
|
||||
deduplicationKey?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification received from the server.
|
||||
*/
|
||||
export interface Notification {
|
||||
id: string;
|
||||
eventType: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
severity: SeverityLevel;
|
||||
metadata?: Record<string, unknown>;
|
||||
actions?: NotificationAction[];
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sending a notification.
|
||||
*/
|
||||
export interface SendResult {
|
||||
success: boolean;
|
||||
notificationId?: string;
|
||||
error?: string;
|
||||
queued?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed API key components.
|
||||
*/
|
||||
export interface ParsedApiKey {
|
||||
key: string;
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the notify client.
|
||||
*/
|
||||
export interface NotifyOptions {
|
||||
/**
|
||||
* API key for authentication.
|
||||
* Format: ak_live_xxx or ak_test_xxx
|
||||
*/
|
||||
apiKey: string;
|
||||
|
||||
/**
|
||||
* API base URL.
|
||||
* @default 'https://api.ironnotify.com'
|
||||
*/
|
||||
apiBaseUrl?: string;
|
||||
|
||||
/**
|
||||
* WebSocket URL for real-time notifications.
|
||||
* @default 'wss://ws.ironnotify.com'
|
||||
*/
|
||||
wsUrl?: string;
|
||||
|
||||
/**
|
||||
* Enable debug logging.
|
||||
* @default false
|
||||
*/
|
||||
debug?: boolean;
|
||||
|
||||
/**
|
||||
* Enable offline queue for failed notifications.
|
||||
* @default true
|
||||
*/
|
||||
enableOfflineQueue?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum size of the offline queue.
|
||||
* @default 100
|
||||
*/
|
||||
maxOfflineQueueSize?: number;
|
||||
|
||||
/**
|
||||
* Auto-reconnect WebSocket on disconnect.
|
||||
* @default true
|
||||
*/
|
||||
autoReconnect?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum reconnection attempts.
|
||||
* @default 5
|
||||
*/
|
||||
maxReconnectAttempts?: number;
|
||||
|
||||
/**
|
||||
* Reconnection delay in milliseconds.
|
||||
* @default 1000
|
||||
*/
|
||||
reconnectDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket connection state.
|
||||
*/
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
||||
|
||||
/**
|
||||
* Event handlers for real-time notifications.
|
||||
*/
|
||||
export interface NotifyEventHandlers {
|
||||
onNotification?: (notification: Notification) => void;
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
onConnectionStateChange?: (state: ConnectionState) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default options for the notify client.
|
||||
*/
|
||||
export function defaultOptions(): Partial<NotifyOptions> {
|
||||
return {
|
||||
apiBaseUrl: 'https://api.ironnotify.com',
|
||||
wsUrl: 'wss://ws.ironnotify.com',
|
||||
debug: false,
|
||||
enableOfflineQueue: true,
|
||||
maxOfflineQueueSize: 100,
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import type { Notification, ConnectionState, NotifyEventHandlers } from './types';
|
||||
|
||||
/**
|
||||
* WebSocket manager for real-time notifications.
|
||||
*/
|
||||
export class WebSocketManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private readonly wsUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly debug: boolean;
|
||||
private readonly autoReconnect: boolean;
|
||||
private readonly maxReconnectAttempts: number;
|
||||
private readonly reconnectDelay: number;
|
||||
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private handlers: NotifyEventHandlers = {};
|
||||
private subscriptions: Set<string> = new Set();
|
||||
private _state: ConnectionState = 'disconnected';
|
||||
|
||||
constructor(
|
||||
wsUrl: string,
|
||||
apiKey: string,
|
||||
debug: boolean,
|
||||
autoReconnect: boolean,
|
||||
maxReconnectAttempts: number,
|
||||
reconnectDelay: number
|
||||
) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.apiKey = apiKey;
|
||||
this.debug = debug;
|
||||
this.autoReconnect = autoReconnect;
|
||||
this.maxReconnectAttempts = maxReconnectAttempts;
|
||||
this.reconnectDelay = reconnectDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state.
|
||||
*/
|
||||
get state(): ConnectionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event handlers.
|
||||
*/
|
||||
setHandlers(handlers: NotifyEventHandlers): void {
|
||||
this.handlers = { ...this.handlers, ...handlers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
if (this.debug) {
|
||||
console.warn('[IronNotify] WebSocket not available in this environment');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState('connecting');
|
||||
|
||||
try {
|
||||
const url = `${this.wsUrl}?token=${encodeURIComponent(this.apiKey)}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.setState('connected');
|
||||
|
||||
if (this.debug) {
|
||||
console.log('[IronNotify] WebSocket connected');
|
||||
}
|
||||
|
||||
// Re-subscribe to any previous subscriptions
|
||||
this.subscriptions.forEach((sub) => {
|
||||
this.sendMessage({ type: 'subscribe', channel: sub });
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
} catch (err) {
|
||||
if (this.debug) {
|
||||
console.error('[IronNotify] Failed to parse message:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
if (this.debug) {
|
||||
console.error('[IronNotify] WebSocket error:', event);
|
||||
}
|
||||
this.handlers.onError?.(new Error('WebSocket error'));
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.setState('disconnected');
|
||||
|
||||
if (this.debug) {
|
||||
console.log('[IronNotify] WebSocket disconnected');
|
||||
}
|
||||
|
||||
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (this.debug) {
|
||||
console.error('[IronNotify] Failed to connect:', err);
|
||||
}
|
||||
this.handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the WebSocket server.
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a user's notifications.
|
||||
*/
|
||||
subscribeToUser(userId: string): void {
|
||||
const channel = `user:${userId}`;
|
||||
this.subscriptions.add(channel);
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage({ type: 'subscribe', channel });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to app-wide notifications.
|
||||
*/
|
||||
subscribeToApp(): void {
|
||||
const channel = 'app';
|
||||
this.subscriptions.add(channel);
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage({ type: 'subscribe', channel });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a channel.
|
||||
*/
|
||||
unsubscribe(channel: string): void {
|
||||
this.subscriptions.delete(channel);
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage({ type: 'unsubscribe', channel });
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
this._state = state;
|
||||
this.handlers.onConnectionStateChange?.(state);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState('reconnecting');
|
||||
this.reconnectAttempts++;
|
||||
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`[IronNotify] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private sendMessage(message: object): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: any): void {
|
||||
switch (data.type) {
|
||||
case 'notification':
|
||||
this.handlers.onNotification?.(data.notification as Notification);
|
||||
break;
|
||||
|
||||
case 'unread_count':
|
||||
this.handlers.onUnreadCountChange?.(data.count);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.handlers.onError?.(new Error(data.message));
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.debug) {
|
||||
console.log('[IronNotify] Unknown message type:', data.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"outDir": "dist/cjs",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"outDir": "dist/esm",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/types",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue