chatly-sdk 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CONTRIBUTING.md +658 -0
  2. package/IMPROVEMENTS.md +402 -0
  3. package/README.md +1538 -164
  4. package/dist/index.d.ts +430 -9
  5. package/dist/index.js +1420 -63
  6. package/examples/01-basic-chat/README.md +61 -0
  7. package/examples/01-basic-chat/index.js +58 -0
  8. package/examples/01-basic-chat/package.json +13 -0
  9. package/examples/02-group-chat/README.md +78 -0
  10. package/examples/02-group-chat/index.js +76 -0
  11. package/examples/02-group-chat/package.json +13 -0
  12. package/examples/03-offline-messaging/README.md +73 -0
  13. package/examples/03-offline-messaging/index.js +80 -0
  14. package/examples/03-offline-messaging/package.json +13 -0
  15. package/examples/04-live-chat/README.md +80 -0
  16. package/examples/04-live-chat/index.js +114 -0
  17. package/examples/04-live-chat/package.json +13 -0
  18. package/examples/05-hybrid-messaging/README.md +71 -0
  19. package/examples/05-hybrid-messaging/index.js +106 -0
  20. package/examples/05-hybrid-messaging/package.json +13 -0
  21. package/examples/06-postgresql-integration/README.md +101 -0
  22. package/examples/06-postgresql-integration/adapters/groupStore.js +73 -0
  23. package/examples/06-postgresql-integration/adapters/messageStore.js +47 -0
  24. package/examples/06-postgresql-integration/adapters/userStore.js +40 -0
  25. package/examples/06-postgresql-integration/index.js +92 -0
  26. package/examples/06-postgresql-integration/package.json +14 -0
  27. package/examples/06-postgresql-integration/schema.sql +58 -0
  28. package/examples/08-customer-support/README.md +70 -0
  29. package/examples/08-customer-support/index.js +104 -0
  30. package/examples/08-customer-support/package.json +13 -0
  31. package/examples/README.md +105 -0
  32. package/jest.config.cjs +28 -0
  33. package/package.json +12 -8
  34. package/src/chat/ChatSession.ts +81 -0
  35. package/src/chat/GroupSession.ts +79 -0
  36. package/src/constants.ts +61 -0
  37. package/src/crypto/e2e.ts +0 -20
  38. package/src/index.ts +525 -63
  39. package/src/models/mediaTypes.ts +58 -0
  40. package/src/models/message.ts +4 -1
  41. package/src/transport/adapters.ts +51 -1
  42. package/src/transport/memoryTransport.ts +75 -13
  43. package/src/transport/websocketClient.ts +269 -21
  44. package/src/transport/websocketServer.ts +26 -26
  45. package/src/utils/errors.ts +97 -0
  46. package/src/utils/logger.ts +96 -0
  47. package/src/utils/mediaUtils.ts +235 -0
  48. package/src/utils/messageQueue.ts +162 -0
  49. package/src/utils/validation.ts +99 -0
  50. package/test/crypto.test.ts +122 -35
  51. package/test/sdk.test.ts +276 -0
  52. package/test/validation.test.ts +64 -0
  53. package/tsconfig.json +11 -10
  54. package/tsconfig.test.json +11 -0
  55. package/src/ChatManager.ts +0 -103
  56. package/src/crypto/keyManager.ts +0 -28
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Log levels
3
+ */
4
+ export enum LogLevel {
5
+ DEBUG = 0,
6
+ INFO = 1,
7
+ WARN = 2,
8
+ ERROR = 3,
9
+ NONE = 4,
10
+ }
11
+
12
+ /**
13
+ * Logger configuration
14
+ */
15
+ export interface LoggerConfig {
16
+ level: LogLevel;
17
+ prefix?: string;
18
+ timestamp?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Simple structured logger
23
+ */
24
+ export class Logger {
25
+ private config: LoggerConfig;
26
+
27
+ constructor(config: Partial<LoggerConfig> = {}) {
28
+ this.config = {
29
+ level: config.level ?? LogLevel.INFO,
30
+ prefix: config.prefix ?? '[ChatSDK]',
31
+ timestamp: config.timestamp ?? true,
32
+ };
33
+ }
34
+
35
+ private shouldLog(level: LogLevel): boolean {
36
+ return level >= this.config.level;
37
+ }
38
+
39
+ private formatMessage(level: string, message: string, data?: unknown): string {
40
+ const parts: string[] = [];
41
+
42
+ if (this.config.timestamp) {
43
+ parts.push(new Date().toISOString());
44
+ }
45
+
46
+ parts.push(this.config.prefix!);
47
+ parts.push(`[${level}]`);
48
+ parts.push(message);
49
+
50
+ let formatted = parts.join(' ');
51
+
52
+ if (data !== undefined) {
53
+ formatted += ' ' + JSON.stringify(data, null, 2);
54
+ }
55
+
56
+ return formatted;
57
+ }
58
+
59
+ debug(message: string, data?: unknown): void {
60
+ if (this.shouldLog(LogLevel.DEBUG)) {
61
+ console.debug(this.formatMessage('DEBUG', message, data));
62
+ }
63
+ }
64
+
65
+ info(message: string, data?: unknown): void {
66
+ if (this.shouldLog(LogLevel.INFO)) {
67
+ console.info(this.formatMessage('INFO', message, data));
68
+ }
69
+ }
70
+
71
+ warn(message: string, data?: unknown): void {
72
+ if (this.shouldLog(LogLevel.WARN)) {
73
+ console.warn(this.formatMessage('WARN', message, data));
74
+ }
75
+ }
76
+
77
+ error(message: string, error?: Error | unknown): void {
78
+ if (this.shouldLog(LogLevel.ERROR)) {
79
+ const errorData = error instanceof Error
80
+ ? { message: error.message, stack: error.stack }
81
+ : error;
82
+ console.error(this.formatMessage('ERROR', message, errorData));
83
+ }
84
+ }
85
+
86
+ setLevel(level: LogLevel): void {
87
+ this.config.level = level;
88
+ }
89
+
90
+ getLevel(): LogLevel {
91
+ return this.config.level;
92
+ }
93
+ }
94
+
95
+ // Default logger instance
96
+ export const logger = new Logger();
@@ -0,0 +1,235 @@
1
+ import { MediaType, MediaMetadata, MediaAttachment, SUPPORTED_MIME_TYPES, FILE_SIZE_LIMITS } from '../models/mediaTypes.js';
2
+ import { ValidationError } from './errors.js';
3
+
4
+ /**
5
+ * Convert File or Blob to base64 string
6
+ */
7
+ export async function encodeFileToBase64(file: File | Blob): Promise<string> {
8
+ return new Promise((resolve, reject) => {
9
+ const reader = new FileReader();
10
+ reader.onload = () => {
11
+ const result = reader.result as string;
12
+ if (!result) {
13
+ reject(new Error('Failed to read file: result is null'));
14
+ return;
15
+ }
16
+ const base64 = result.split(',')[1];
17
+ if (!base64) {
18
+ reject(new Error('Failed to extract base64 data'));
19
+ return;
20
+ }
21
+ resolve(base64);
22
+ };
23
+ reader.onerror = () => reject(new Error('Failed to read file'));
24
+ reader.readAsDataURL(file);
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Convert base64 string to Blob
30
+ */
31
+ export function decodeBase64ToBlob(base64: string, mimeType: string): Blob {
32
+ const byteCharacters = atob(base64);
33
+ const byteNumbers = new Array(byteCharacters.length);
34
+
35
+ for (let i = 0; i < byteCharacters.length; i++) {
36
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
37
+ }
38
+
39
+ const byteArray = new Uint8Array(byteNumbers);
40
+ return new Blob([byteArray], { type: mimeType });
41
+ }
42
+
43
+ /**
44
+ * Detect media type from MIME type
45
+ */
46
+ export function getMediaType(mimeType: string): MediaType {
47
+ if (SUPPORTED_MIME_TYPES.image.includes(mimeType)) {
48
+ return MediaType.IMAGE;
49
+ }
50
+ if (SUPPORTED_MIME_TYPES.audio.includes(mimeType)) {
51
+ return MediaType.AUDIO;
52
+ }
53
+ if (SUPPORTED_MIME_TYPES.video.includes(mimeType)) {
54
+ return MediaType.VIDEO;
55
+ }
56
+ if (SUPPORTED_MIME_TYPES.document.includes(mimeType)) {
57
+ return MediaType.DOCUMENT;
58
+ }
59
+ throw new ValidationError(`Unsupported MIME type: ${mimeType}`);
60
+ }
61
+
62
+ /**
63
+ * Validate media file
64
+ */
65
+ export function validateMediaFile(file: File | Blob, filename?: string): void {
66
+ const mimeType = file.type;
67
+
68
+ // Check if MIME type is supported
69
+ const mediaType = getMediaType(mimeType);
70
+
71
+ // Check file size
72
+ const maxSize = FILE_SIZE_LIMITS[mediaType];
73
+ if (file.size > maxSize) {
74
+ throw new ValidationError(
75
+ `File size exceeds limit. Max size for ${mediaType}: ${maxSize / 1024 / 1024}MB`
76
+ );
77
+ }
78
+
79
+ // Validate filename if provided
80
+ if (filename && filename.length > 255) {
81
+ throw new ValidationError('Filename too long (max 255 characters)');
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Create media metadata from file
87
+ */
88
+ export async function createMediaMetadata(
89
+ file: File | Blob,
90
+ filename?: string
91
+ ): Promise<MediaMetadata> {
92
+ const actualFilename = filename || (file instanceof File ? file.name : 'file');
93
+
94
+ const metadata: MediaMetadata = {
95
+ filename: actualFilename,
96
+ mimeType: file.type,
97
+ size: file.size,
98
+ };
99
+
100
+ // For images, try to get dimensions
101
+ if (file.type.startsWith('image/')) {
102
+ try {
103
+ const dimensions = await getImageDimensions(file);
104
+ metadata.width = dimensions.width;
105
+ metadata.height = dimensions.height;
106
+
107
+ // Generate thumbnail for images
108
+ metadata.thumbnail = await generateThumbnail(file);
109
+ } catch (error) {
110
+ // Dimensions optional, continue without them
111
+ }
112
+ }
113
+
114
+ // For audio/video, duration would need to be provided by the caller
115
+ // as we can't reliably get it in Node.js without external libraries
116
+
117
+ return metadata;
118
+ }
119
+
120
+ /**
121
+ * Get image dimensions
122
+ */
123
+ function getImageDimensions(file: Blob): Promise<{ width: number; height: number }> {
124
+ return new Promise((resolve, reject) => {
125
+ const img = new Image();
126
+ const url = URL.createObjectURL(file);
127
+
128
+ img.onload = () => {
129
+ URL.revokeObjectURL(url);
130
+ resolve({ width: img.width, height: img.height });
131
+ };
132
+
133
+ img.onerror = () => {
134
+ URL.revokeObjectURL(url);
135
+ reject(new Error('Failed to load image'));
136
+ };
137
+
138
+ img.src = url;
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Generate thumbnail for image (max 200x200)
144
+ */
145
+ async function generateThumbnail(file: Blob): Promise<string> {
146
+ return new Promise((resolve, reject) => {
147
+ const img = new Image();
148
+ const url = URL.createObjectURL(file);
149
+
150
+ img.onload = () => {
151
+ URL.revokeObjectURL(url);
152
+
153
+ // Calculate thumbnail size (max 200x200, maintain aspect ratio)
154
+ const maxSize = 200;
155
+ let width = img.width;
156
+ let height = img.height;
157
+
158
+ if (width > height) {
159
+ if (width > maxSize) {
160
+ height = (height * maxSize) / width;
161
+ width = maxSize;
162
+ }
163
+ } else {
164
+ if (height > maxSize) {
165
+ width = (width * maxSize) / height;
166
+ height = maxSize;
167
+ }
168
+ }
169
+
170
+ // Create canvas and draw thumbnail
171
+ const canvas = document.createElement('canvas');
172
+ canvas.width = width;
173
+ canvas.height = height;
174
+ const ctx = canvas.getContext('2d');
175
+
176
+ if (!ctx) {
177
+ reject(new Error('Failed to get canvas context'));
178
+ return;
179
+ }
180
+
181
+ ctx.drawImage(img, 0, 0, width, height);
182
+
183
+ // Convert to base64
184
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
185
+ const thumbnail = dataUrl.split(',')[1];
186
+ if (!thumbnail) {
187
+ reject(new Error('Failed to generate thumbnail'));
188
+ return;
189
+ }
190
+ resolve(thumbnail);
191
+ };
192
+
193
+ img.onerror = () => {
194
+ URL.revokeObjectURL(url);
195
+ reject(new Error('Failed to load image for thumbnail'));
196
+ };
197
+
198
+ img.src = url;
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Create media attachment from file
204
+ */
205
+ export async function createMediaAttachment(
206
+ file: File | Blob,
207
+ filename?: string
208
+ ): Promise<MediaAttachment> {
209
+ // Validate file
210
+ validateMediaFile(file, filename);
211
+
212
+ // Get media type
213
+ const mediaType = getMediaType(file.type);
214
+
215
+ // Encode file to base64
216
+ const data = await encodeFileToBase64(file);
217
+
218
+ // Create metadata
219
+ const metadata = await createMediaMetadata(file, filename);
220
+
221
+ return {
222
+ type: mediaType,
223
+ data,
224
+ metadata,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Format file size for display
230
+ */
231
+ export function formatFileSize(bytes: number): string {
232
+ if (bytes < 1024) return `${bytes} B`;
233
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
234
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
235
+ }
@@ -0,0 +1,162 @@
1
+ import { Message } from '../models/message.js';
2
+ import { logger } from './logger.js';
3
+ import { MAX_QUEUE_SIZE, MESSAGE_RETRY_ATTEMPTS, MESSAGE_RETRY_DELAY, MessageStatus } from '../constants.js';
4
+
5
+ /**
6
+ * Queued message with retry metadata
7
+ */
8
+ export interface QueuedMessage {
9
+ message: Message;
10
+ status: MessageStatus;
11
+ attempts: number;
12
+ lastAttempt?: number;
13
+ error?: Error;
14
+ }
15
+
16
+ /**
17
+ * Message queue for offline support and retry logic
18
+ */
19
+ export class MessageQueue {
20
+ private queue: Map<string, QueuedMessage> = new Map();
21
+ private maxSize: number;
22
+ private maxRetries: number;
23
+ private retryDelay: number;
24
+
25
+ constructor(
26
+ maxSize: number = MAX_QUEUE_SIZE,
27
+ maxRetries: number = MESSAGE_RETRY_ATTEMPTS,
28
+ retryDelay: number = MESSAGE_RETRY_DELAY
29
+ ) {
30
+ this.maxSize = maxSize;
31
+ this.maxRetries = maxRetries;
32
+ this.retryDelay = retryDelay;
33
+ }
34
+
35
+ /**
36
+ * Add a message to the queue
37
+ */
38
+ enqueue(message: Message): void {
39
+ if (this.queue.size >= this.maxSize) {
40
+ logger.warn('Message queue is full, removing oldest message');
41
+ const firstKey = this.queue.keys().next().value;
42
+ if (firstKey) {
43
+ this.queue.delete(firstKey);
44
+ }
45
+ }
46
+
47
+ this.queue.set(message.id, {
48
+ message,
49
+ status: MessageStatus.PENDING,
50
+ attempts: 0,
51
+ });
52
+
53
+ logger.debug('Message enqueued', { messageId: message.id });
54
+ }
55
+
56
+ /**
57
+ * Mark a message as sent
58
+ */
59
+ markSent(messageId: string): void {
60
+ const queued = this.queue.get(messageId);
61
+ if (queued) {
62
+ queued.status = MessageStatus.SENT;
63
+ logger.debug('Message marked as sent', { messageId });
64
+ // Remove from queue after successful send
65
+ this.queue.delete(messageId);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Mark a message as failed
71
+ */
72
+ markFailed(messageId: string, error: Error): void {
73
+ const queued = this.queue.get(messageId);
74
+ if (queued) {
75
+ queued.status = MessageStatus.FAILED;
76
+ queued.error = error;
77
+ queued.attempts++;
78
+ queued.lastAttempt = Date.now();
79
+
80
+ logger.warn('Message failed', {
81
+ messageId,
82
+ attempts: queued.attempts,
83
+ error: error.message,
84
+ });
85
+
86
+ // Remove if max retries exceeded
87
+ if (queued.attempts >= this.maxRetries) {
88
+ logger.error('Message exceeded max retries, removing from queue', {
89
+ messageId,
90
+ attempts: queued.attempts,
91
+ });
92
+ this.queue.delete(messageId);
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get messages that need to be retried
99
+ */
100
+ getRetryableMessages(): QueuedMessage[] {
101
+ const now = Date.now();
102
+ const retryable: QueuedMessage[] = [];
103
+
104
+ for (const queued of this.queue.values()) {
105
+ if (
106
+ queued.status === MessageStatus.FAILED &&
107
+ queued.attempts < this.maxRetries &&
108
+ (!queued.lastAttempt || now - queued.lastAttempt >= this.retryDelay)
109
+ ) {
110
+ retryable.push(queued);
111
+ }
112
+ }
113
+
114
+ return retryable;
115
+ }
116
+
117
+ /**
118
+ * Get all pending messages
119
+ */
120
+ getPendingMessages(): QueuedMessage[] {
121
+ return Array.from(this.queue.values()).filter(
122
+ (q) => q.status === MessageStatus.PENDING
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Get queue size
128
+ */
129
+ size(): number {
130
+ return this.queue.size;
131
+ }
132
+
133
+ /**
134
+ * Clear the queue
135
+ */
136
+ clear(): void {
137
+ this.queue.clear();
138
+ logger.debug('Message queue cleared');
139
+ }
140
+
141
+ /**
142
+ * Get message by ID
143
+ */
144
+ get(messageId: string): QueuedMessage | undefined {
145
+ return this.queue.get(messageId);
146
+ }
147
+
148
+ /**
149
+ * Remove message from queue
150
+ */
151
+ remove(messageId: string): void {
152
+ this.queue.delete(messageId);
153
+ logger.debug('Message removed from queue', { messageId });
154
+ }
155
+
156
+ /**
157
+ * Get all messages in queue
158
+ */
159
+ getAll(): QueuedMessage[] {
160
+ return Array.from(this.queue.values());
161
+ }
162
+ }
@@ -0,0 +1,99 @@
1
+ import { ValidationError } from "./errors.js";
2
+
3
+ /**
4
+ * Validation utilities
5
+ */
6
+
7
+ const USERNAME_REGEX = /^[a-zA-Z0-9_-]{3,20}$/;
8
+ const MAX_MESSAGE_LENGTH = 10000;
9
+ const MIN_GROUP_MEMBERS = 2;
10
+ const MAX_GROUP_MEMBERS = 256;
11
+ const MAX_GROUP_NAME_LENGTH = 100;
12
+
13
+ /**
14
+ * Validate username format
15
+ */
16
+ export function validateUsername(username: string): void {
17
+ if (!username || typeof username !== 'string') {
18
+ throw new ValidationError('Username is required', { username });
19
+ }
20
+
21
+ if (!USERNAME_REGEX.test(username)) {
22
+ throw new ValidationError(
23
+ 'Username must be 3-20 characters and contain only letters, numbers, underscores, and hyphens',
24
+ { username }
25
+ );
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Validate message content
31
+ */
32
+ export function validateMessage(message: string): void {
33
+ if (!message || typeof message !== 'string') {
34
+ throw new ValidationError('Message content is required');
35
+ }
36
+
37
+ if (message.length === 0) {
38
+ throw new ValidationError('Message cannot be empty');
39
+ }
40
+
41
+ if (message.length > MAX_MESSAGE_LENGTH) {
42
+ throw new ValidationError(
43
+ `Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`,
44
+ { length: message.length, max: MAX_MESSAGE_LENGTH }
45
+ );
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Validate group name
51
+ */
52
+ export function validateGroupName(name: string): void {
53
+ if (!name || typeof name !== 'string') {
54
+ throw new ValidationError('Group name is required');
55
+ }
56
+
57
+ if (name.trim().length === 0) {
58
+ throw new ValidationError('Group name cannot be empty');
59
+ }
60
+
61
+ if (name.length > MAX_GROUP_NAME_LENGTH) {
62
+ throw new ValidationError(
63
+ `Group name exceeds maximum length of ${MAX_GROUP_NAME_LENGTH} characters`,
64
+ { length: name.length, max: MAX_GROUP_NAME_LENGTH }
65
+ );
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Validate group members count
71
+ */
72
+ export function validateGroupMembers(memberCount: number): void {
73
+ if (memberCount < MIN_GROUP_MEMBERS) {
74
+ throw new ValidationError(
75
+ `Group must have at least ${MIN_GROUP_MEMBERS} members`,
76
+ { count: memberCount, min: MIN_GROUP_MEMBERS }
77
+ );
78
+ }
79
+
80
+ if (memberCount > MAX_GROUP_MEMBERS) {
81
+ throw new ValidationError(
82
+ `Group cannot have more than ${MAX_GROUP_MEMBERS} members`,
83
+ { count: memberCount, max: MAX_GROUP_MEMBERS }
84
+ );
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Validate user ID format
90
+ */
91
+ export function validateUserId(userId: string): void {
92
+ if (!userId || typeof userId !== 'string') {
93
+ throw new ValidationError('User ID is required');
94
+ }
95
+
96
+ if (userId.trim().length === 0) {
97
+ throw new ValidationError('User ID cannot be empty');
98
+ }
99
+ }