From bd63d195ee34564e6f299ac74b887bd1d2e0293f Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 10:34:21 +0000 Subject: [PATCH] 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 --- .gitignore | 26 ++++ README.md | 333 +++++++++++++++++++++++++++++++++++++++++++- package.json | 50 +++++++ src/builder.ts | 139 ++++++++++++++++++ src/client.ts | 245 ++++++++++++++++++++++++++++++++ src/config.ts | 62 +++++++++ src/index.ts | 153 ++++++++++++++++++++ src/queue.ts | 97 +++++++++++++ src/transport.ts | 178 +++++++++++++++++++++++ src/types.ts | 154 ++++++++++++++++++++ src/websocket.ts | 223 +++++++++++++++++++++++++++++ tsconfig.cjs.json | 9 ++ tsconfig.esm.json | 9 ++ tsconfig.json | 18 +++ tsconfig.types.json | 9 ++ 15 files changed, 1703 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/builder.ts create mode 100644 src/client.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/queue.ts create mode 100644 src/transport.ts create mode 100644 src/types.ts create mode 100644 src/websocket.ts create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json create mode 100644 tsconfig.types.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c438719 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 96fae68..6c10025 100644 --- a/README.md +++ b/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. + +[![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([]); + 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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e5a49c6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/builder.ts b/src/builder.ts new file mode 100644 index 0000000..0d6d921 --- /dev/null +++ b/src/builder.ts @@ -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 = {}; + 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): 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 { + const payload = this.build(); + return this.client.sendPayload(payload); + } +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..839e0ee --- /dev/null +++ b/src/client.ts @@ -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; + private readonly transport: Transport; + private readonly queue: OfflineQueue; + private readonly wsManager: WebSocketManager; + private isOnline = true; + private flushTimer: ReturnType | 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; + userId?: string; + } + ): Promise { + 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 { + 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 { + const result = await this.transport.getNotifications(options); + return result?.notifications ?? []; + } + + /** + * Get unread notification count. + */ + async getUnreadCount(): Promise { + return this.transport.getUnreadCount(); + } + + /** + * Mark a notification as read. + */ + async markAsRead(notificationId: string): Promise { + return this.transport.markAsRead(notificationId); + } + + /** + * Mark all notifications as read. + */ + async markAllAsRead(): Promise { + return this.transport.markAllAsRead(); + } + + /** + * Flush the offline queue. + */ + async flush(): Promise { + 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 + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b1a82e1 --- /dev/null +++ b/src/config.ts @@ -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 { + 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!, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9b1b752 --- /dev/null +++ b/src/index.ts @@ -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; + userId?: string; + } +): Promise { + 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 { + 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 { + 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; + } +} diff --git a/src/queue.ts b/src/queue.ts new file mode 100644 index 0000000..a6aac5a --- /dev/null +++ b/src/queue.ts @@ -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 + } + } +} diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..227f4da --- /dev/null +++ b/src/transport.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f32979a --- /dev/null +++ b/src/types.ts @@ -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; + 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; + 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 { + return { + apiBaseUrl: 'https://api.ironnotify.com', + wsUrl: 'wss://ws.ironnotify.com', + debug: false, + enableOfflineQueue: true, + maxOfflineQueueSize: 100, + autoReconnect: true, + maxReconnectAttempts: 5, + reconnectDelay: 1000, + }; +} diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..b6c6968 --- /dev/null +++ b/src/websocket.ts @@ -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 | null = null; + private handlers: NotifyEventHandlers = {}; + private subscriptions: Set = 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); + } + } + } +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..ef6d1cb --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist/cjs", + "declaration": false, + "declarationMap": false + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..e6f9e51 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "outDir": "dist/esm", + "declaration": false, + "declarationMap": false + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6db7261 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..c7077b0 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/types", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true + } +}