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:
David Friedel 2025-12-25 10:34:21 +00:00
parent de08d57638
commit bd63d195ee
15 changed files with 1703 additions and 2 deletions

26
.gitignore vendored Normal file
View File

@ -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
View File

@ -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.
[![npm version](https://img.shields.io/npm/v/@ironservices/notify.svg)](https://www.npmjs.com/package/@ironservices/notify)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.

50
package.json Normal file
View File

@ -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"
}
}

139
src/builder.ts Normal file
View File

@ -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);
}
}

245
src/client.ts Normal file
View File

@ -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
}
}

62
src/config.ts Normal file
View File

@ -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!,
};
}

153
src/index.ts Normal file
View File

@ -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;
}
}

97
src/queue.ts Normal file
View File

@ -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
}
}
}

178
src/transport.ts Normal file
View File

@ -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;
}
}
}

154
src/types.ts Normal file
View File

@ -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,
};
}

223
src/websocket.ts Normal file
View File

@ -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);
}
}
}
}

9
tsconfig.cjs.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "dist/cjs",
"declaration": false,
"declarationMap": false
}
}

9
tsconfig.esm.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "dist/esm",
"declaration": false,
"declarationMap": false
}
}

18
tsconfig.json Normal file
View File

@ -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"]
}

9
tsconfig.types.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist/types",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true
}
}