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
|
||||||
IronNotify SDK for JavaScript/TypeScript - Event notifications and alerts
|
|
||||||
|
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