dolphin-server-modules 2.11.1 → 2.11.3

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 (112) hide show
  1. package/TUTORIAL_NEPALI.md +181 -0
  2. package/dist/adapters/mongoose/index.test.d.ts +1 -0
  3. package/dist/adapters/mongoose/index.test.js +145 -0
  4. package/dist/adapters/mongoose/index.test.js.map +1 -0
  5. package/dist/adapters/mongoose/integration.test.d.ts +5 -0
  6. package/dist/adapters/mongoose/integration.test.js +217 -0
  7. package/dist/adapters/mongoose/integration.test.js.map +1 -0
  8. package/dist/ai/dolphin-agent/agent.d.ts +5 -0
  9. package/dist/ai/dolphin-agent/agent.js +93 -46
  10. package/dist/ai/dolphin-agent/agent.js.map +1 -1
  11. package/dist/ai/dolphin-agent/config.js +19 -23
  12. package/dist/ai/dolphin-agent/config.js.map +1 -1
  13. package/dist/auth/auth.test.d.ts +1 -0
  14. package/dist/auth/auth.test.js +286 -0
  15. package/dist/auth/auth.test.js.map +1 -0
  16. package/dist/authController/authController.test.d.ts +1 -0
  17. package/dist/authController/authController.test.js +359 -0
  18. package/dist/authController/authController.test.js.map +1 -0
  19. package/dist/bin/cli.js +494 -131
  20. package/dist/bin/cli.js.map +1 -1
  21. package/dist/client.test.d.ts +22 -0
  22. package/dist/client.test.js +573 -0
  23. package/dist/client.test.js.map +1 -0
  24. package/dist/controller/controller.test.d.ts +1 -0
  25. package/dist/controller/controller.test.js +37 -0
  26. package/dist/controller/controller.test.js.map +1 -0
  27. package/dist/curd/crud.test.d.ts +1 -0
  28. package/dist/curd/crud.test.js +104 -0
  29. package/dist/curd/crud.test.js.map +1 -0
  30. package/dist/demo-server.d.ts +1 -0
  31. package/dist/demo-server.js +191 -0
  32. package/dist/demo-server.js.map +1 -0
  33. package/dist/djson/djson.test.d.ts +1 -0
  34. package/dist/djson/djson.test.js +200 -0
  35. package/dist/djson/djson.test.js.map +1 -0
  36. package/dist/dolphin-bench.d.ts +1 -0
  37. package/dist/dolphin-bench.js +63 -0
  38. package/dist/dolphin-bench.js.map +1 -0
  39. package/dist/hard-performance-test.d.ts +1 -0
  40. package/dist/hard-performance-test.js +97 -0
  41. package/dist/hard-performance-test.js.map +1 -0
  42. package/dist/index.d.ts +12 -0
  43. package/dist/index.js +11 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/middleware/zod.test.d.ts +1 -0
  46. package/dist/middleware/zod.test.js +74 -0
  47. package/dist/middleware/zod.test.js.map +1 -0
  48. package/dist/performance-test.d.ts +1 -0
  49. package/dist/performance-test.js +92 -0
  50. package/dist/performance-test.js.map +1 -0
  51. package/dist/real-test-mongoose.d.ts +1 -0
  52. package/dist/real-test-mongoose.js +104 -0
  53. package/dist/real-test-mongoose.js.map +1 -0
  54. package/dist/realtime/camera.d.ts +119 -0
  55. package/dist/realtime/camera.js +299 -0
  56. package/dist/realtime/camera.js.map +1 -0
  57. package/dist/realtime/camera.test.d.ts +1 -0
  58. package/dist/realtime/camera.test.js +345 -0
  59. package/dist/realtime/camera.test.js.map +1 -0
  60. package/dist/realtime/core.d.ts +4 -4
  61. package/dist/realtime/core.js +5 -5
  62. package/dist/realtime/core.js.map +1 -1
  63. package/dist/realtime/index.d.ts +2 -0
  64. package/dist/realtime/index.js +2 -0
  65. package/dist/realtime/index.js.map +1 -1
  66. package/dist/realtime/realtime.test.d.ts +1 -0
  67. package/dist/realtime/realtime.test.js +623 -0
  68. package/dist/realtime/realtime.test.js.map +1 -0
  69. package/dist/realtime/rtsp.d.ts +65 -0
  70. package/dist/realtime/rtsp.js +410 -0
  71. package/dist/realtime/rtsp.js.map +1 -0
  72. package/dist/realtime/rtsp.test.d.ts +1 -0
  73. package/dist/realtime/rtsp.test.js +361 -0
  74. package/dist/realtime/rtsp.test.js.map +1 -0
  75. package/dist/router/router.test.d.ts +1 -0
  76. package/dist/router/router.test.js +45 -0
  77. package/dist/router/router.test.js.map +1 -0
  78. package/dist/server/server.d.ts +8 -8
  79. package/dist/server/server.js +1 -10
  80. package/dist/server/server.js.map +1 -1
  81. package/dist/server/server.test.d.ts +1 -0
  82. package/dist/server/server.test.js +299 -0
  83. package/dist/server/server.test.js.map +1 -0
  84. package/dist/services/ai-service.js +22 -11
  85. package/dist/services/ai-service.js.map +1 -1
  86. package/dist/signaling/signaling.test.d.ts +1 -0
  87. package/dist/signaling/signaling.test.js +112 -0
  88. package/dist/signaling/signaling.test.js.map +1 -0
  89. package/dist/swagger/swagger.test.d.ts +1 -0
  90. package/dist/swagger/swagger.test.js +38 -0
  91. package/dist/swagger/swagger.test.js.map +1 -0
  92. package/dist/templates/index.d.ts +6 -0
  93. package/dist/templates/index.js +247 -70
  94. package/dist/templates/index.js.map +1 -1
  95. package/dist/test-2fa-real.d.ts +1 -0
  96. package/dist/test-2fa-real.js +105 -0
  97. package/dist/test-2fa-real.js.map +1 -0
  98. package/dist/test-dolphin.d.ts +1 -0
  99. package/dist/test-dolphin.js +98 -0
  100. package/dist/test-dolphin.js.map +1 -0
  101. package/dist/utils/ctx.d.ts +50 -0
  102. package/dist/utils/ctx.js +82 -0
  103. package/dist/utils/ctx.js.map +1 -0
  104. package/package.json +171 -65
  105. package/scripts/client.js +838 -703
  106. package/scripts/benchmark.js +0 -12
  107. package/scripts/benchmark.ts +0 -12
  108. package/scripts/list-models.js +0 -34
  109. package/scripts/run-real-ai-test.js +0 -79
  110. package/scripts/test-ai-logic.js +0 -44
  111. package/scripts/test-client.js +0 -105
  112. package/scripts/test-dolphin.js +0 -36
package/scripts/client.js CHANGED
@@ -1,703 +1,838 @@
1
- /**
2
- * @typedef {Object} DolphinResponse
3
- * @property {boolean} success
4
- * @property {any} [data]
5
- * @property {string} [message]
6
- * @property {number} [status]
7
- */
8
-
9
- /**
10
- * @typedef {Object} SignalMessage
11
- * @property {string} msgId
12
- * @property {string} type
13
- * @property {string} from
14
- * @property {string} to
15
- * @property {any} data
16
- * @property {number} timestamp
17
- */
18
-
19
- /**
20
- * @typedef {Object} FileMetadata
21
- * @property {string} fileId
22
- * @property {string} name
23
- * @property {number} size
24
- * @property {number} totalChunks
25
- * @property {number} chunkSize
26
- */
27
-
28
- /**
29
- * @callback TopicCallback
30
- * @param {any} payload
31
- * @param {string} [topic]
32
- */
33
-
34
- /**
35
- * Dolphin Client v2.0 - Full-stack Realtime, API & Auth Client
36
- * Zero-dependency, pure JS.
37
- *
38
- * यो लाइब्रेरी डल्फिन सर्भरबाट सिधै उपलब्ध हुने पब-सब, API र Auth लाइब्रेरी हो।
39
- */
40
-
41
- class APIHandler {
42
- /**
43
- * @param {DolphinClient} client
44
- */
45
- constructor(client) {
46
- this.client = client;
47
- return this._createProxy([]);
48
- }
49
-
50
- /**
51
- * @param {string[]} pathParts
52
- * @private
53
- */
54
- _createProxy(pathParts) {
55
- const path = pathParts.join('/');
56
-
57
- const target = (options) => {
58
- return this.request('GET', path, null, options);
59
- };
60
-
61
- // Add standard methods to the function target
62
- target.get = (pathOrOptions, options) => {
63
- if (typeof pathOrOptions === 'string') return this.request('GET', pathOrOptions, null, options);
64
- return this.request('GET', path, null, pathOrOptions);
65
- };
66
- target.post = (pathOrBody, bodyOrOptions, options) => {
67
- if (typeof pathOrBody === 'string') return this.request('POST', pathOrBody, bodyOrOptions, options);
68
- return this.request('POST', path, pathOrBody, bodyOrOptions);
69
- };
70
- target.put = (pathOrBody, bodyOrOptions, options) => {
71
- if (typeof pathOrBody === 'string') return this.request('PUT', pathOrBody, bodyOrOptions, options);
72
- return this.request('PUT', path, pathOrBody, bodyOrOptions);
73
- };
74
- target.del = (pathOrOptions, options) => {
75
- if (typeof pathOrOptions === 'string') return this.request('DELETE', pathOrOptions, null, options);
76
- return this.request('DELETE', path, null, pathOrOptions);
77
- };
78
- target.request = (method, subPath, body, options) => {
79
- const finalPath = subPath ? `${path}/${subPath.startsWith('/') ? subPath.slice(1) : subPath}` : path;
80
- return this.request(method, finalPath, body, options);
81
- };
82
-
83
- const methods = ['get', 'post', 'put', 'del', 'request'];
84
-
85
- return new Proxy(target, {
86
- get: (t, prop) => {
87
- if (typeof prop === 'string' && !methods.includes(prop)) {
88
- return this._createProxy([...pathParts, prop]);
89
- }
90
- return t[prop];
91
- }
92
- });
93
- }
94
-
95
- /**
96
- * @param {string} method
97
- * @param {string} path
98
- * @param {any} [body]
99
- * @param {RequestInit} [options]
100
- * @returns {Promise<any>}
101
- */
102
- async request(method, path, body = null, options = {}) {
103
- const url = `${this.client.httpUrl}${path.startsWith('/') ? path : '/' + path}`;
104
- const headers = {
105
- 'Content-Type': 'application/json',
106
- ...options.headers
107
- };
108
-
109
- if (this.client.accessToken) {
110
- headers['Authorization'] = `Bearer ${this.client.accessToken}`;
111
- }
112
-
113
- const fetchOptions = {
114
- method,
115
- headers,
116
- ...options
117
- };
118
-
119
- if (body) {
120
- fetchOptions.body = JSON.stringify(body);
121
- }
122
-
123
- const response = await fetch(url, fetchOptions);
124
- const contentType = response.headers.get('content-type');
125
- let data;
126
- if (contentType && contentType.includes('application/json')) {
127
- data = await response.json();
128
- } else {
129
- data = await response.text();
130
- }
131
-
132
- if (!response.ok) {
133
- throw { status: response.status, data };
134
- }
135
-
136
- return data;
137
- }
138
-
139
- /**
140
- * @param {string|RequestInit} [pathOrOptions]
141
- * @param {RequestInit} [options]
142
- * @returns {Promise<any>}
143
- */
144
- get(pathOrOptions, options) { return Promise.resolve(); }
145
-
146
- /**
147
- * @param {string|any} [pathOrBody]
148
- * @param {any} [bodyOrOptions]
149
- * @param {RequestInit} [options]
150
- * @returns {Promise<any>}
151
- */
152
- post(pathOrBody, bodyOrOptions, options) { return Promise.resolve(); }
153
-
154
- /**
155
- * @param {string|any} [pathOrBody]
156
- * @param {any} [bodyOrOptions]
157
- * @param {RequestInit} [options]
158
- * @returns {Promise<any>}
159
- */
160
- put(pathOrBody, bodyOrOptions, options) { return Promise.resolve(); }
161
-
162
- /**
163
- * @param {string|RequestInit} [pathOrOptions]
164
- * @param {RequestInit} [options]
165
- * @returns {Promise<any>}
166
- */
167
- del(pathOrOptions, options) { return Promise.resolve(); }
168
- }
169
-
170
- class AuthHandler {
171
- /**
172
- * @param {DolphinClient} client
173
- */
174
- constructor(client) {
175
- this.client = client;
176
- this.user = null;
177
- }
178
-
179
- async login(email, password) {
180
- const res = await this.client.api.post('/auth/login', { email, password });
181
- if (res.accessToken) {
182
- this.client.setToken(res.accessToken);
183
- this.user = res.user;
184
- }
185
- return res;
186
- }
187
-
188
- async register(data) {
189
- return await this.client.api.post('/auth/register', data);
190
- }
191
-
192
- async me() {
193
- const res = await this.client.api.get('/auth/me');
194
- if (res.success) {
195
- this.user = res.data;
196
- }
197
- return res;
198
- }
199
-
200
- async logout() {
201
- await this.client.api.post('/auth/logout');
202
- this.client.setToken(null);
203
- this.user = null;
204
- }
205
-
206
- /** @param {string} email */
207
- async forgotPassword(email) {
208
- return await this.client.api.post('/auth/forgot-password', { email });
209
- }
210
- }
211
-
212
- /**
213
- * DolphinStore - Reactive State Sync (Zustand Alternative)
214
- * Automatically syncs database collections with local state.
215
- */
216
- class DolphinStore {
217
- /** @param {DolphinClient} client */
218
- constructor(client) {
219
- this.client = client;
220
- /** @type {Map<string, { items: any[], loading: boolean, error: string|null, success: boolean }>} */
221
- this.data = new Map();
222
- /** @type {Set<function()>} */
223
- this.listeners = new Set();
224
- /** @type {Set<string>} */
225
- this.subscribed = new Set();
226
-
227
- return new Proxy(this, {
228
- get: (target, prop) => {
229
- if (prop in target) return target[prop];
230
- if (typeof prop === 'string') {
231
- return this._getCollection(prop);
232
- }
233
- }
234
- });
235
- }
236
-
237
- /** @private */
238
- _getCollection(name) {
239
- if (!this.data.has(name)) {
240
- const collection = {
241
- _rawItems: [],
242
- items: [],
243
- loading: true,
244
- error: null,
245
- success: false,
246
- _filter: null,
247
- _sort: null,
248
-
249
- /**
250
- * फिल्टर सेट गर्ने (Local filtering)
251
- * @param {function(any): boolean} fn
252
- */
253
- where: (fn) => {
254
- collection._filter = fn;
255
- this._applyTransform(collection);
256
- return collection;
257
- },
258
-
259
- /**
260
- * सर्टिङ सेट गर्ने
261
- * @param {string} key
262
- * @param {'asc'|'desc'} [direction]
263
- */
264
- orderBy: (key, direction = 'asc') => {
265
- collection._sort = { key, direction };
266
- this._applyTransform(collection);
267
- return collection;
268
- },
269
-
270
- /**
271
- * फिल्टर सर्ट हटाउने
272
- */
273
- clear: () => {
274
- collection._filter = null;
275
- collection._sort = null;
276
- this._applyTransform(collection);
277
- return collection;
278
- }
279
- };
280
-
281
- this.data.set(name, collection);
282
- this._fetchAndSync(name);
283
- }
284
- return this.data.get(name);
285
- }
286
-
287
- /** @private */
288
- async _fetchAndSync(name) {
289
- const state = this.data.get(name);
290
- try {
291
- // 1. Initial Fetch
292
- const res = await this.client.api.get(`/${name.toLowerCase()}`);
293
- state._rawItems = Array.isArray(res) ? res : (res.data || []);
294
- state.loading = false;
295
- state.success = true;
296
- state.error = null;
297
- this._applyTransform(state);
298
-
299
- // 2. Realtime Sync (if connected)
300
- if (!this.subscribed.has(name)) {
301
- const topic = `db:sync/${name.toLowerCase()}`;
302
- this.client.subscribe(topic, (update) => {
303
- this._handleRemoteUpdate(name, update);
304
- });
305
- this.subscribed.add(name);
306
- }
307
- } catch (e) {
308
- state.loading = false;
309
- state.success = false;
310
- state.error = e.data?.error || e.message || 'Fetch failed';
311
- this._notify();
312
- console.error(`[DolphinStore] Sync failed for ${name}:`, e);
313
- }
314
- }
315
-
316
- /** @private */
317
- _applyTransform(state) {
318
- let result = [...state._rawItems];
319
-
320
- // 1. Filter
321
- if (state._filter) {
322
- result = result.filter(state._filter);
323
- }
324
-
325
- // 2. Sort
326
- if (state._sort) {
327
- const { key, direction } = state._sort;
328
- result.sort((a, b) => {
329
- const av = a[key];
330
- const bv = b[key];
331
- if (av === bv) return 0;
332
- const compare = av > bv ? 1 : -1;
333
- return direction === 'asc' ? compare : -compare;
334
- });
335
- }
336
-
337
- state.items = result;
338
- this._notify();
339
- }
340
-
341
- /** @private */
342
- _handleRemoteUpdate(collection, update) {
343
- const state = this.data.get(collection);
344
- if (!state) return;
345
-
346
- let items = state._rawItems;
347
- const { type, data } = update; // type: 'create', 'update', 'delete'
348
-
349
- if (type === 'create') {
350
- items = [...items, data];
351
- } else if (type === 'update') {
352
- items = items.map(item => (item.id === data.id || item._id === data._id) ? { ...item, ...data } : item);
353
- } else if (type === 'delete') {
354
- items = items.filter(item => (item.id !== data.id && item._id !== data._id));
355
- }
356
-
357
- state._rawItems = items;
358
- this._applyTransform(state);
359
- }
360
-
361
- /** Subscribe for React components (useSyncExternalStore) */
362
- subscribe(listener) {
363
- this.listeners.add(listener);
364
- return () => this.listeners.delete(listener);
365
- }
366
-
367
- getSnapshot(collection) {
368
- return this.data.get(collection) || { items: [], loading: false, error: null, success: false };
369
- }
370
-
371
- _notify() {
372
- this.listeners.forEach(l => l());
373
- }
374
- }
375
-
376
- class DolphinClient {
377
- /**
378
- * @param {string} [url]
379
- * @param {string} [deviceId]
380
- */
381
- constructor(url = '', deviceId = '') {
382
- // Handle URL formatting
383
- if (!url && typeof window !== 'undefined') {
384
- url = window.location.host;
385
- }
386
-
387
- let protocol = 'http:';
388
- if (url && url.startsWith('https://')) {
389
- protocol = 'https:';
390
- } else if (url && url.startsWith('http://')) {
391
- protocol = 'http:';
392
- } else if (typeof window !== 'undefined') {
393
- protocol = window.location.protocol;
394
- }
395
-
396
- this.host = (url || 'localhost').replace(/\/$/, "").replace(/^https?:\/\//, "");
397
- this.httpUrl = `${protocol}//${this.host}`;
398
- this.deviceId = deviceId || 'web_' + Math.random().toString(36).substr(2, 5);
399
-
400
- /** @type {WebSocket | null} */
401
- this.socket = null;
402
-
403
- // Polyfill Storage if not browser
404
- this.storage = typeof localStorage !== 'undefined' ? localStorage : {
405
- getItem: (key) => null,
406
- setItem: (key, val) => { },
407
- removeItem: (key) => { }
408
- };
409
-
410
- /** @type {string | null} */
411
- this.accessToken = this.storage.getItem('dolphin_token');
412
-
413
- // Sub-handlers
414
- this.api = new APIHandler(this);
415
- this.auth = new AuthHandler(this);
416
- this.store = new DolphinStore(this);
417
-
418
- /** @type {Map<string, Set<TopicCallback>>} */
419
- this.handlers = new Map(); // topic -> Set of callbacks
420
- /** @type {Set<function(SignalMessage): void>} */
421
- this.signalHandlers = new Set();
422
- /** @type {Set<function(FileMetadata): void>} */
423
- this.fileHandlers = new Set();
424
-
425
- this.reconnectAttempts = 0;
426
- this.maxReconnectAttempts = 5;
427
- }
428
-
429
- /**
430
- * टोकन सेट गर्ने र सेभ गर्ने
431
- */
432
- setToken(token) {
433
- this.accessToken = token;
434
- if (token) {
435
- this.storage.setItem('dolphin_token', token);
436
- } else {
437
- this.storage.removeItem('dolphin_token');
438
- }
439
- }
440
-
441
- /**
442
- * रियल-टाइम सर्भरसँग कनेक्शन सुरु गर्ने
443
- * @returns {Promise<void>}
444
- */
445
- async connect() {
446
- return new Promise((resolve, reject) => {
447
- const protocol = this.httpUrl.startsWith('https') ? 'wss:' : 'ws:';
448
- const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
449
-
450
- console.log(`[Dolphin] Connecting to ${wsUrl}...`);
451
- this.socket = new WebSocket(wsUrl);
452
-
453
- this.socket.onopen = () => {
454
- console.log(`[Dolphin] Connected as "${this.deviceId}" 🐬`);
455
- this.reconnectAttempts = 0;
456
- resolve();
457
- };
458
-
459
- this.socket.onmessage = (event) => {
460
- this._handleMessage(event.data);
461
- };
462
-
463
- this.socket.onclose = () => {
464
- console.warn("[Dolphin] Connection closed");
465
- this._maybeReconnect();
466
- };
467
-
468
- this.socket.onerror = (err) => {
469
- console.error("[Dolphin] WebSocket Error:", err);
470
- reject(err);
471
- };
472
- });
473
- }
474
-
475
- _handleMessage(data) {
476
- try {
477
- const parsed = JSON.parse(data);
478
-
479
- // १. Signaling Messages
480
- if (parsed.type && parsed.from && (parsed.to === this.deviceId || parsed.to === 'all')) {
481
- // Auto-ACK for signaling messages
482
- if (parsed.msgId && parsed.type !== 'ACK') {
483
- this._sendAck(parsed.from, parsed.msgId);
484
- }
485
- this.signalHandlers.forEach(handler => handler(parsed));
486
- }
487
-
488
- // २. File & Data Responses
489
- if (parsed.type === 'FILE_AVAILABLE') {
490
- this.fileHandlers.forEach(handler => handler(parsed));
491
- }
492
- if (parsed.type === 'FILE_CHUNK') {
493
- this.saveFileProgress(parsed.fileId, parsed.chunkIndex);
494
- this.handlers.forEach((callbacks, pattern) => {
495
- if (pattern === 'file:chunk' || pattern === `file:chunk/${parsed.fileId}`) {
496
- callbacks.forEach(cb => cb(parsed));
497
- }
498
- });
499
- }
500
- if (parsed.type === 'PULL_RESPONSE') {
501
- this.handlers.forEach((callbacks, pattern) => {
502
- if (pattern === 'pull:response' || pattern === `pull:response/${parsed.topic}`) {
503
- callbacks.forEach(cb => cb(parsed.payload, parsed.topic));
504
- }
505
- });
506
- }
507
-
508
- // ३. Pub/Sub Messages
509
- if (parsed.topic && parsed.payload !== undefined) {
510
- const topic = parsed.topic;
511
- this.handlers.forEach((callbacks, pattern) => {
512
- if (this._matchTopic(pattern, topic)) {
513
- callbacks.forEach(cb => cb(parsed.payload, topic));
514
- }
515
- });
516
- }
517
- } catch (e) {
518
- this.handlers.forEach((callbacks, pattern) => {
519
- if (pattern === 'raw') callbacks.forEach(cb => cb(data));
520
- });
521
- }
522
- }
523
-
524
- /**
525
- * @param {string} to
526
- * @param {string} msgId
527
- * @private
528
- */
529
- _sendAck(to, msgId) {
530
- this.publish(`phone/signaling/${to}`, {
531
- type: 'ACK',
532
- from: this.deviceId,
533
- to: to,
534
- data: { ackId: msgId },
535
- timestamp: Date.now()
536
- });
537
- }
538
-
539
- _matchTopic(pattern, topic) {
540
- if (pattern === topic || pattern === '#') return true;
541
- const pParts = pattern.split('/');
542
- const tParts = topic.split('/');
543
- if (pParts.length !== tParts.length && !pattern.includes('#')) return false;
544
- for (let i = 0; i < pParts.length; i++) {
545
- if (pParts[i] === '#') return true;
546
- if (pParts[i] !== '+' && pParts[i] !== tParts[i]) return false;
547
- }
548
- return pParts.length === tParts.length;
549
- }
550
-
551
- /**
552
- * @param {string} topic
553
- * @param {TopicCallback} callback
554
- */
555
- subscribe(topic, callback) {
556
- if (!this.handlers.has(topic)) {
557
- this.handlers.set(topic, new Set());
558
- // Tell server we want to sub
559
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
560
- this.socket.send(JSON.stringify({ type: 'sub', topic }));
561
- }
562
- }
563
- this.handlers.get(topic).add(callback);
564
- }
565
-
566
- /**
567
- * @param {string} topic
568
- * @param {TopicCallback} callback
569
- */
570
- unsubscribe(topic, callback) {
571
- if (this.handlers.has(topic)) {
572
- const callbacks = this.handlers.get(topic);
573
- callbacks.delete(callback);
574
- if (callbacks.size === 0) {
575
- this.handlers.delete(topic);
576
- // Tell server we want to unsub
577
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
578
- this.socket.send(JSON.stringify({ type: 'unsub', topic }));
579
- }
580
- }
581
- }
582
- }
583
-
584
- /**
585
- * @param {string} topic
586
- * @param {any} payload
587
- */
588
- publish(topic, payload) {
589
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
590
- this.socket.send(JSON.stringify({ topic, payload }));
591
- }
592
- }
593
-
594
- /**
595
- * High-frequency data push
596
- * @param {string} topic
597
- * @param {any} payload
598
- */
599
- pubPush(topic, payload) {
600
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
601
- this.socket.send(JSON.stringify({ type: 'pub', topic, payload }));
602
- }
603
- }
604
-
605
- /**
606
- * Request historical data from topic
607
- * @param {string} topic
608
- * @param {number} [count]
609
- */
610
- subPull(topic, count = 10) {
611
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
612
- this.socket.send(JSON.stringify({
613
- type: 'PULL_REQUEST',
614
- topic,
615
- count
616
- }));
617
- }
618
- }
619
-
620
- /**
621
- * Start downloading a file by chunks
622
- * @param {string} fileId
623
- * @param {number} [startChunk]
624
- */
625
- subFile(fileId, startChunk = 0) {
626
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
627
- this.socket.send(JSON.stringify({
628
- type: 'FILE_REQUEST',
629
- fileId,
630
- startChunk
631
- }));
632
- }
633
- }
634
-
635
- /**
636
- * Resume a file download from last saved progress
637
- * @param {string} fileId
638
- */
639
- resumeFile(fileId) {
640
- const lastChunk = parseInt(localStorage.getItem(`dolphin_file_${fileId}`) || "-1");
641
- this.subFile(fileId, lastChunk + 1);
642
- }
643
-
644
- /**
645
- * Save download progress
646
- * @param {string} fileId
647
- * @param {number} chunkIndex
648
- */
649
- saveFileProgress(fileId, chunkIndex) {
650
- this.storage.setItem(`dolphin_file_${fileId}`, chunkIndex.toString());
651
- }
652
-
653
- /**
654
- * @param {function(SignalMessage): void} handler
655
- */
656
- onSignal(handler) {
657
- this.signalHandlers.add(handler);
658
- }
659
-
660
- /**
661
- * @param {function(SignalMessage): void} handler
662
- */
663
- offSignal(handler) {
664
- this.signalHandlers.delete(handler);
665
- }
666
-
667
- /**
668
- * @param {function(FileMetadata): void} handler
669
- */
670
- onFileAvailable(handler) {
671
- this.fileHandlers.add(handler);
672
- }
673
-
674
- /**
675
- * @param {function(FileMetadata): void} handler
676
- */
677
- offFileAvailable(handler) {
678
- this.fileHandlers.delete(handler);
679
- }
680
-
681
- _maybeReconnect() {
682
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
683
- this.reconnectAttempts++;
684
- const delay = Math.pow(2, this.reconnectAttempts) * 1000;
685
- setTimeout(() => this.connect(), delay);
686
- }
687
- }
688
- }
689
-
690
- // Browser Global Export
691
- if (typeof window !== 'undefined') {
692
- // @ts-ignore
693
- window.DolphinClient = DolphinClient;
694
- // @ts-ignore
695
- window.dolphin = new DolphinClient();
696
- }
697
-
698
- // NodeJS/CommonJS/ESM Export support
699
- if (typeof module !== 'undefined' && module.exports) {
700
- module.exports = { DolphinClient };
701
- }
702
-
703
- export { DolphinClient };
1
+ /**
2
+ * Dolphin Client v2.1 — Full-stack Realtime, API & Auth Client
3
+ * Zero-dependency, pure JS. Works in Browser + Node.js + React Native.
4
+ *
5
+ * Fixed in v2.1:
6
+ * - pubFile() — file upload (chunked)
7
+ * - Request timeout — AbortController with configurable timeout
8
+ * - auth.refresh() — auto access-token refresh
9
+ * - auth.verify2FA() — 2FA code verification
10
+ * - Offline queue — publish queue when WS is disconnected
11
+ * - Improved JSDoc — full TypeScript-compatible type hints
12
+ */
13
+
14
+ // ─── JSDoc Types ─────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * @typedef {Object} DolphinResponse
18
+ * @property {boolean} success
19
+ * @property {any} [data]
20
+ * @property {string} [message]
21
+ * @property {number} [status]
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} SignalMessage
26
+ * @property {string} msgId
27
+ * @property {string} type
28
+ * @property {string} from
29
+ * @property {string} to
30
+ * @property {any} data
31
+ * @property {number} timestamp
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} FileMetadata
36
+ * @property {string} fileId
37
+ * @property {string} name
38
+ * @property {number} size
39
+ * @property {number} totalChunks
40
+ * @property {number} chunkSize
41
+ */
42
+
43
+ /**
44
+ * @callback TopicCallback
45
+ * @param {any} payload
46
+ * @param {string} [topic]
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} DolphinClientOptions
51
+ * @property {number} [timeout=15000] HTTP request timeout ms
52
+ * @property {number} [chunkSize=65536] — file upload chunk size (bytes)
53
+ * @property {number} [maxReconnect=5] — max WebSocket reconnect attempts
54
+ * @property {boolean} [autoRefreshToken=true] — auto-refresh expired access token
55
+ */
56
+
57
+ // ─── APIHandler ───────────────────────────────────────────────────────────────
58
+
59
+ class APIHandler {
60
+ /** @param {DolphinClient} client */
61
+ constructor(client) {
62
+ this.client = client;
63
+ return this._createProxy([]);
64
+ }
65
+
66
+ /** @private */
67
+ _createProxy(pathParts) {
68
+ const joined = pathParts.join('/');
69
+
70
+ const target = (options) => this.request('GET', joined, null, options);
71
+
72
+ target.get = (pathOrOptions, options) =>
73
+ typeof pathOrOptions === 'string'
74
+ ? this.request('GET', pathOrOptions, null, options)
75
+ : this.request('GET', joined, null, pathOrOptions);
76
+
77
+ target.post = (pathOrBody, bodyOrOptions, options) =>
78
+ typeof pathOrBody === 'string'
79
+ ? this.request('POST', pathOrBody, bodyOrOptions, options)
80
+ : this.request('POST', joined, pathOrBody, bodyOrOptions);
81
+
82
+ target.put = (pathOrBody, bodyOrOptions, options) =>
83
+ typeof pathOrBody === 'string'
84
+ ? this.request('PUT', pathOrBody, bodyOrOptions, options)
85
+ : this.request('PUT', joined, pathOrBody, bodyOrOptions);
86
+
87
+ target.patch = (pathOrBody, bodyOrOptions, options) =>
88
+ typeof pathOrBody === 'string'
89
+ ? this.request('PATCH', pathOrBody, bodyOrOptions, options)
90
+ : this.request('PATCH', joined, pathOrBody, bodyOrOptions);
91
+
92
+ target.del = (pathOrOptions, options) =>
93
+ typeof pathOrOptions === 'string'
94
+ ? this.request('DELETE', pathOrOptions, null, options)
95
+ : this.request('DELETE', joined, null, pathOrOptions);
96
+
97
+ target.request = (method, subPath, body, options) => {
98
+ const finalPath = subPath
99
+ ? `${joined}/${subPath.startsWith('/') ? subPath.slice(1) : subPath}`
100
+ : joined;
101
+ return this.request(method, finalPath, body, options);
102
+ };
103
+
104
+ const methods = ['get', 'post', 'put', 'patch', 'del', 'request'];
105
+
106
+ return new Proxy(target, {
107
+ get: (t, prop) => {
108
+ if (typeof prop === 'string' && !methods.includes(prop)) {
109
+ return this._createProxy([...pathParts, prop]);
110
+ }
111
+ return t[prop];
112
+ }
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Make an HTTP request with timeout + auto token refresh.
118
+ * @param {string} method
119
+ * @param {string} path
120
+ * @param {any} [body]
121
+ * @param {RequestInit} [options]
122
+ * @param {boolean} [_isRetry=false] — internal: prevent infinite refresh loop
123
+ * @returns {Promise<any>}
124
+ */
125
+ async request(method, path, body = null, options = {}, _isRetry = false) {
126
+ const url = `${this.client.httpUrl}${path.startsWith('/') ? path : '/' + path}`;
127
+
128
+ const controller = new AbortController();
129
+ const timeoutId = setTimeout(
130
+ () => controller.abort(),
131
+ this.client.options.timeout
132
+ );
133
+
134
+ const headers = {
135
+ 'Content-Type': 'application/json',
136
+ ...(options.headers || {}),
137
+ };
138
+ if (this.client.accessToken) {
139
+ headers['Authorization'] = `Bearer ${this.client.accessToken}`;
140
+ }
141
+
142
+ try {
143
+ const response = await fetch(url, {
144
+ method,
145
+ headers,
146
+ signal: controller.signal,
147
+ ...(body ? { body: JSON.stringify(body) } : {}),
148
+ ...options,
149
+ });
150
+
151
+ clearTimeout(timeoutId);
152
+
153
+ // Auto-refresh: 401 + not a retry + autoRefreshToken enabled
154
+ if (
155
+ response.status === 401 &&
156
+ !_isRetry &&
157
+ this.client.options.autoRefreshToken
158
+ ) {
159
+ const refreshed = await this.client.auth._silentRefresh();
160
+ if (refreshed) {
161
+ return this.request(method, path, body, options, true);
162
+ }
163
+ }
164
+
165
+ const contentType = response.headers.get('content-type') || '';
166
+ const data = contentType.includes('application/json')
167
+ ? await response.json()
168
+ : await response.text();
169
+
170
+ if (!response.ok) throw { status: response.status, data };
171
+ return data;
172
+
173
+ } catch (err) {
174
+ clearTimeout(timeoutId);
175
+ if (err.name === 'AbortError') {
176
+ throw { status: 408, data: { error: 'Request timed out' } };
177
+ }
178
+ throw err;
179
+ }
180
+ }
181
+ }
182
+
183
+ // ─── AuthHandler ──────────────────────────────────────────────────────────────
184
+
185
+ class AuthHandler {
186
+ /** @param {DolphinClient} client */
187
+ constructor(client) {
188
+ this.client = client;
189
+ /** @type {any|null} */
190
+ this.user = null;
191
+ this._refreshing = false;
192
+ }
193
+
194
+ /**
195
+ * Login with email + password.
196
+ * @param {string} email
197
+ * @param {string} password
198
+ */
199
+ async login(email, password) {
200
+ const res = await this.client.api.post('/auth/login', { email, password });
201
+ if (res.accessToken) {
202
+ this.client.setToken(res.accessToken);
203
+ this.user = res.user || null;
204
+ }
205
+ return res;
206
+ }
207
+
208
+ /**
209
+ * Register a new account.
210
+ * @param {{ email: string, password: string, [key: string]: any }} data
211
+ */
212
+ async register(data) {
213
+ return this.client.api.post('/auth/register', data);
214
+ }
215
+
216
+ /** Get current user profile. */
217
+ async me() {
218
+ const res = await this.client.api.get('/auth/me');
219
+ if (res.success) this.user = res.data;
220
+ return res;
221
+ }
222
+
223
+ /** Logout and clear token. */
224
+ async logout() {
225
+ try { await this.client.api.post('/auth/logout'); } catch {}
226
+ this.client.setToken(null);
227
+ this.user = null;
228
+ }
229
+
230
+ /**
231
+ * Manually refresh the access token using the httpOnly refresh-token cookie.
232
+ * Called automatically on 401 if autoRefreshToken is enabled.
233
+ * @returns {Promise<boolean>} — true if refresh succeeded
234
+ */
235
+ async refresh() {
236
+ return this._silentRefresh();
237
+ }
238
+
239
+ /** @private */
240
+ async _silentRefresh() {
241
+ if (this._refreshing) return false;
242
+ this._refreshing = true;
243
+ try {
244
+ const res = await this.client.api.post('/auth/refresh', null, {}, true);
245
+ if (res.accessToken) {
246
+ this.client.setToken(res.accessToken);
247
+ return true;
248
+ }
249
+ return false;
250
+ } catch {
251
+ this.client.setToken(null);
252
+ return false;
253
+ } finally {
254
+ this._refreshing = false;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Verify a 2FA TOTP code after login.
260
+ * @param {string} code — 6-digit TOTP code
261
+ * @param {string} [email] — email (if not already in user)
262
+ */
263
+ async verify2FA(code, email) {
264
+ const payload = {
265
+ code,
266
+ email: email || this.user?.email,
267
+ };
268
+ const res = await this.client.api.post('/auth/2fa/verify', payload);
269
+ if (res.accessToken) {
270
+ this.client.setToken(res.accessToken);
271
+ if (res.user) this.user = res.user;
272
+ }
273
+ return res;
274
+ }
275
+
276
+ /**
277
+ * Enable 2FA — returns QR code URL and secret.
278
+ */
279
+ async enable2FA() {
280
+ return this.client.api.post('/auth/2fa/enable');
281
+ }
282
+
283
+ /**
284
+ * Disable 2FA.
285
+ * @param {string} code — current TOTP code to confirm
286
+ */
287
+ async disable2FA(code) {
288
+ return this.client.api.post('/auth/2fa/disable', { code });
289
+ }
290
+
291
+ /**
292
+ * Request a password reset email.
293
+ * @param {string} email
294
+ */
295
+ async forgotPassword(email) {
296
+ return this.client.api.post('/auth/forgot-password', { email });
297
+ }
298
+
299
+ /**
300
+ * Reset password using the token from email.
301
+ * @param {string} token
302
+ * @param {string} newPassword
303
+ */
304
+ async resetPassword(token, newPassword) {
305
+ return this.client.api.post('/auth/reset-password', { token, newPassword });
306
+ }
307
+ }
308
+
309
+ // ─── DolphinStore ─────────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Reactive state sync — auto-fetches collections and keeps them live
313
+ * via WebSocket pub/sub. Works with React useSyncExternalStore.
314
+ */
315
+ class DolphinStore {
316
+ /** @param {DolphinClient} client */
317
+ constructor(client) {
318
+ this.client = client;
319
+ /** @type {Map<string, any>} */
320
+ this.data = new Map();
321
+ /** @type {Set<function()>} */
322
+ this.listeners = new Set();
323
+ /** @type {Set<string>} */
324
+ this.subscribed = new Set();
325
+
326
+ return new Proxy(this, {
327
+ get: (target, prop) => {
328
+ if (prop in target) return target[prop];
329
+ if (typeof prop === 'string') return this._getCollection(prop);
330
+ }
331
+ });
332
+ }
333
+
334
+ /** @private */
335
+ _getCollection(name) {
336
+ if (!this.data.has(name)) {
337
+ const collection = {
338
+ _rawItems: [],
339
+ items: [],
340
+ loading: true,
341
+ error: null,
342
+ success: false,
343
+ _filter: null,
344
+ _sort: null,
345
+
346
+ where: (fn) => {
347
+ collection._filter = fn;
348
+ this._applyTransform(collection);
349
+ return collection;
350
+ },
351
+ orderBy: (key, direction = 'asc') => {
352
+ collection._sort = { key, direction };
353
+ this._applyTransform(collection);
354
+ return collection;
355
+ },
356
+ reset: () => {
357
+ collection._filter = null;
358
+ collection._sort = null;
359
+ this._applyTransform(collection);
360
+ return collection;
361
+ },
362
+ };
363
+
364
+ this.data.set(name, collection);
365
+ this._fetchAndSync(name);
366
+ }
367
+ return this.data.get(name);
368
+ }
369
+
370
+ /** @private */
371
+ async _fetchAndSync(name) {
372
+ const state = this.data.get(name);
373
+ try {
374
+ const res = await this.client.api.get(`/${name.toLowerCase()}`);
375
+ state._rawItems = Array.isArray(res) ? res : (res.data || []);
376
+ state.loading = false;
377
+ state.success = true;
378
+ state.error = null;
379
+ this._applyTransform(state);
380
+
381
+ if (!this.subscribed.has(name)) {
382
+ this.client.subscribe(`db:sync/${name.toLowerCase()}`, (update) => {
383
+ this._handleRemoteUpdate(name, update);
384
+ });
385
+ this.subscribed.add(name);
386
+ }
387
+ } catch (e) {
388
+ state.loading = false;
389
+ state.success = false;
390
+ state.error = e.data?.error || e.message || 'Fetch failed';
391
+ this._notify();
392
+ }
393
+ }
394
+
395
+ /** @private */
396
+ _applyTransform(state) {
397
+ let result = [...state._rawItems];
398
+ if (state._filter) result = result.filter(state._filter);
399
+ if (state._sort) {
400
+ const { key, direction } = state._sort;
401
+ result.sort((a, b) => {
402
+ if (a[key] === b[key]) return 0;
403
+ return (a[key] > b[key] ? 1 : -1) * (direction === 'asc' ? 1 : -1);
404
+ });
405
+ }
406
+ state.items = result;
407
+ this._notify();
408
+ }
409
+
410
+ /** @private */
411
+ _handleRemoteUpdate(collection, update) {
412
+ const state = this.data.get(collection);
413
+ if (!state) return;
414
+ const { type, data } = update;
415
+ let items = state._rawItems;
416
+
417
+ if (type === 'create') {
418
+ items = [...items, data];
419
+ } else if (type === 'update') {
420
+ items = items.map(i => (i.id === data.id || i._id === data._id) ? { ...i, ...data } : i);
421
+ } else if (type === 'delete') {
422
+ items = items.filter(i => {
423
+ if (data.id != null && i.id === data.id) return false;
424
+ if (data._id != null && i._id === data._id) return false;
425
+ return true;
426
+ });
427
+ }
428
+
429
+ state._rawItems = items;
430
+ this._applyTransform(state);
431
+ }
432
+
433
+ /** Subscribe for React useSyncExternalStore */
434
+ subscribe(listener) {
435
+ this.listeners.add(listener);
436
+ return () => this.listeners.delete(listener);
437
+ }
438
+
439
+ /** @param {string} collection */
440
+ getSnapshot(collection) {
441
+ return this.data.get(collection) || { items: [], loading: false, error: null, success: false };
442
+ }
443
+
444
+ /** @private */
445
+ _notify() {
446
+ this.listeners.forEach(l => l());
447
+ }
448
+ }
449
+
450
+ // ─── DolphinClient ────────────────────────────────────────────────────────────
451
+
452
+ class DolphinClient {
453
+ /**
454
+ * @param {string} [url]
455
+ * @param {string} [deviceId]
456
+ * @param {DolphinClientOptions} [options]
457
+ */
458
+ constructor(url = '', deviceId = '', options = {}) {
459
+ if (!url && typeof window !== 'undefined') url = window.location.host;
460
+
461
+ let protocol = 'http:';
462
+ if (url.startsWith('https://')) protocol = 'https:';
463
+ else if (url.startsWith('http://')) protocol = 'http:';
464
+ else if (typeof window !== 'undefined') protocol = window.location.protocol;
465
+
466
+ this.host = (url || 'localhost').replace(/\/$/, '').replace(/^https?:\/\//, '');
467
+ this.httpUrl = `${protocol}//${this.host}`;
468
+ this.deviceId = deviceId || 'web_' + Math.random().toString(36).substr(2, 8);
469
+
470
+ /** @type {DolphinClientOptions} */
471
+ this.options = {
472
+ timeout: 15000,
473
+ chunkSize: 65536, // 64 KB
474
+ maxReconnect: 5,
475
+ autoRefreshToken: true,
476
+ ...options,
477
+ };
478
+
479
+ /** @type {WebSocket|null} */
480
+ this.socket = null;
481
+
482
+ // Storage polyfill
483
+ this.storage = typeof localStorage !== 'undefined' ? localStorage : {
484
+ getItem: () => null,
485
+ setItem: () => {},
486
+ removeItem: () => {},
487
+ };
488
+
489
+ /** @type {string|null} */
490
+ this.accessToken = this.storage.getItem('dolphin_token');
491
+
492
+ // Sub-handlers
493
+ this.api = new APIHandler(this);
494
+ this.auth = new AuthHandler(this);
495
+ this.store = new DolphinStore(this);
496
+
497
+ /** @type {Map<string, Set<TopicCallback>>} */
498
+ this.handlers = new Map();
499
+ /** @type {Set<function(SignalMessage): void>} */
500
+ this.signalHandlers = new Set();
501
+ /** @type {Set<function(FileMetadata): void>} */
502
+ this.fileHandlers = new Set();
503
+
504
+ /** @type {Array<string>} — offline message queue */
505
+ this._offlineQueue = [];
506
+
507
+ this.reconnectAttempts = 0;
508
+ }
509
+
510
+ /** Save or clear the access token */
511
+ setToken(token) {
512
+ this.accessToken = token;
513
+ token
514
+ ? this.storage.setItem('dolphin_token', token)
515
+ : this.storage.removeItem('dolphin_token');
516
+ }
517
+
518
+ // ── WebSocket ─────────────────────────────────────────────────────────────
519
+
520
+ /** Connect to the Dolphin realtime server */
521
+ async connect() {
522
+ return new Promise((resolve, reject) => {
523
+ const protocol = this.httpUrl.startsWith('https') ? 'wss:' : 'ws:';
524
+ const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
525
+
526
+ console.log(`[Dolphin] Connecting to ${wsUrl}...`);
527
+ this.socket = new WebSocket(wsUrl);
528
+
529
+ this.socket.onopen = () => {
530
+ console.log(`[Dolphin] Connected as "${this.deviceId}" 🐬`);
531
+ this.reconnectAttempts = 0;
532
+ this._flushOfflineQueue();
533
+ resolve();
534
+ };
535
+ this.socket.onmessage = (ev) => this._handleMessage(ev.data);
536
+ this.socket.onclose = () => {
537
+ console.warn('[Dolphin] Connection closed');
538
+ this._maybeReconnect();
539
+ };
540
+ this.socket.onerror = (err) => {
541
+ console.error('[Dolphin] WebSocket error:', err);
542
+ reject(err);
543
+ };
544
+ });
545
+ }
546
+
547
+ /** Disconnect cleanly */
548
+ disconnect() {
549
+ if (this.socket) {
550
+ this.socket.onclose = null; // prevent auto-reconnect
551
+ this.socket.close();
552
+ this.socket = null;
553
+ }
554
+ }
555
+
556
+ /** @private */
557
+ _handleMessage(data) {
558
+ try {
559
+ const msg = JSON.parse(data);
560
+
561
+ // Signaling
562
+ if (msg.type && msg.from && (msg.to === this.deviceId || msg.to === 'all')) {
563
+ if (msg.msgId && msg.type !== 'ACK') this._sendAck(msg.from, msg.msgId);
564
+ this.signalHandlers.forEach(h => h(msg));
565
+ }
566
+
567
+ // File events
568
+ if (msg.type === 'FILE_AVAILABLE') {
569
+ this.fileHandlers.forEach(h => h(msg));
570
+ }
571
+ if (msg.type === 'FILE_CHUNK') {
572
+ this.saveFileProgress(msg.fileId, msg.chunkIndex);
573
+ this._dispatch('file:chunk', msg);
574
+ this._dispatch(`file:chunk/${msg.fileId}`, msg);
575
+ }
576
+ if (msg.type === 'FILE_UPLOAD_ACK') {
577
+ this._dispatch(`file:upload:ack/${msg.fileId}`, msg);
578
+ }
579
+
580
+ // Pull response
581
+ if (msg.type === 'PULL_RESPONSE') {
582
+ this._dispatch('pull:response', msg.payload, msg.topic);
583
+ this._dispatch(`pull:response/${msg.topic}`, msg.payload, msg.topic);
584
+ }
585
+
586
+ // Pub/Sub
587
+ if (msg.topic && msg.payload !== undefined) {
588
+ this.handlers.forEach((cbs, pattern) => {
589
+ if (this._matchTopic(pattern, msg.topic)) {
590
+ cbs.forEach(cb => cb(msg.payload, msg.topic));
591
+ }
592
+ });
593
+ }
594
+ } catch {
595
+ this._dispatch('raw', data);
596
+ }
597
+ }
598
+
599
+ /** @private */
600
+ _dispatch(pattern, payload, topic) {
601
+ const cbs = this.handlers.get(pattern);
602
+ if (cbs) cbs.forEach(cb => cb(payload, topic || pattern));
603
+ }
604
+
605
+ /** @private */
606
+ _sendRaw(msg) {
607
+ const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
608
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
609
+ this.socket.send(str);
610
+ } else {
611
+ // Buffer for offline queue (max 100 messages)
612
+ if (this._offlineQueue.length < 100) this._offlineQueue.push(str);
613
+ }
614
+ }
615
+
616
+ /** Flush buffered messages after reconnect @private */
617
+ _flushOfflineQueue() {
618
+ while (this._offlineQueue.length > 0) {
619
+ const msg = this._offlineQueue.shift();
620
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
621
+ this.socket.send(msg);
622
+ }
623
+ }
624
+ }
625
+
626
+ /** @private */
627
+ _sendAck(to, msgId) {
628
+ this._sendRaw({ type: 'ACK', from: this.deviceId, to, data: { ackId: msgId }, timestamp: Date.now() });
629
+ }
630
+
631
+ /** MQTT wildcard topic matching @private */
632
+ _matchTopic(pattern, topic) {
633
+ if (pattern === topic || pattern === '#') return true;
634
+ const pp = pattern.split('/');
635
+ const tp = topic.split('/');
636
+ if (pp.length !== tp.length && !pattern.includes('#')) return false;
637
+ for (let i = 0; i < pp.length; i++) {
638
+ if (pp[i] === '#') return true;
639
+ if (pp[i] !== '+' && pp[i] !== tp[i]) return false;
640
+ }
641
+ return pp.length === tp.length;
642
+ }
643
+
644
+ /** @private */
645
+ _maybeReconnect() {
646
+ if (this.reconnectAttempts < this.options.maxReconnect) {
647
+ this.reconnectAttempts++;
648
+ const delay = Math.pow(2, this.reconnectAttempts) * 1000;
649
+ console.log(`[Dolphin] Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts})...`);
650
+ setTimeout(() => this.connect().catch(() => {}), delay);
651
+ } else {
652
+ console.error('[Dolphin] Max reconnect attempts reached.');
653
+ }
654
+ }
655
+
656
+ // ── Pub/Sub ───────────────────────────────────────────────────────────────
657
+
658
+ /**
659
+ * Subscribe to a topic (MQTT wildcards supported: + and #).
660
+ * @param {string} topic
661
+ * @param {TopicCallback} callback
662
+ */
663
+ subscribe(topic, callback) {
664
+ if (!this.handlers.has(topic)) {
665
+ this.handlers.set(topic, new Set());
666
+ this._sendRaw({ type: 'sub', topic });
667
+ }
668
+ this.handlers.get(topic).add(callback);
669
+ }
670
+
671
+ /**
672
+ * Unsubscribe from a topic.
673
+ * @param {string} topic
674
+ * @param {TopicCallback} callback
675
+ */
676
+ unsubscribe(topic, callback) {
677
+ if (this.handlers.has(topic)) {
678
+ const cbs = this.handlers.get(topic);
679
+ cbs.delete(callback);
680
+ if (cbs.size === 0) {
681
+ this.handlers.delete(topic);
682
+ this._sendRaw({ type: 'unsub', topic });
683
+ }
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Publish a message to a topic. Queued if offline.
689
+ * @param {string} topic
690
+ * @param {any} payload
691
+ */
692
+ publish(topic, payload) {
693
+ this._sendRaw({ topic, payload });
694
+ }
695
+
696
+ /**
697
+ * High-frequency data push (IoT sensors).
698
+ * @param {string} topic
699
+ * @param {any} payload
700
+ */
701
+ pubPush(topic, payload) {
702
+ this._sendRaw({ type: 'pub', topic, payload });
703
+ }
704
+
705
+ /**
706
+ * Request historical data from a topic.
707
+ * @param {string} topic
708
+ * @param {number} [count=10]
709
+ */
710
+ subPull(topic, count = 10) {
711
+ this._sendRaw({ type: 'PULL_REQUEST', topic, count });
712
+ }
713
+
714
+ // ── File Transfer ─────────────────────────────────────────────────────────
715
+
716
+ /**
717
+ * Upload a file to the server in chunks.
718
+ * @param {string} fileId
719
+ * @param {Blob|ArrayBuffer|Uint8Array} fileData
720
+ * @param {string} [filename]
721
+ * @param {function(number): void} [onProgress] — progress callback (0-100)
722
+ * @returns {Promise<void>}
723
+ */
724
+ async pubFile(fileId, fileData, filename = '', onProgress) {
725
+ let buffer;
726
+ if (fileData instanceof Blob) {
727
+ buffer = await fileData.arrayBuffer();
728
+ } else if (fileData instanceof ArrayBuffer) {
729
+ buffer = fileData;
730
+ } else {
731
+ buffer = fileData.buffer || fileData;
732
+ }
733
+
734
+ const bytes = new Uint8Array(buffer);
735
+ const chunkSize = this.options.chunkSize;
736
+ const totalChunks = Math.ceil(bytes.length / chunkSize);
737
+
738
+ // Send file metadata first
739
+ this._sendRaw({
740
+ type: 'FILE_UPLOAD_START',
741
+ fileId,
742
+ name: filename,
743
+ size: bytes.length,
744
+ totalChunks,
745
+ chunkSize,
746
+ });
747
+
748
+ for (let i = 0; i < totalChunks; i++) {
749
+ const chunk = bytes.slice(i * chunkSize, (i + 1) * chunkSize);
750
+ const b64 = this._uint8ToBase64(chunk);
751
+
752
+ this._sendRaw({
753
+ type: 'FILE_UPLOAD_CHUNK',
754
+ fileId,
755
+ chunkIndex: i,
756
+ totalChunks,
757
+ data: b64,
758
+ });
759
+
760
+ if (onProgress) onProgress(Math.round(((i + 1) / totalChunks) * 100));
761
+
762
+ // Small yield to prevent blocking
763
+ if (i % 10 === 0) await new Promise(r => setTimeout(r, 0));
764
+ }
765
+
766
+ this._sendRaw({ type: 'FILE_UPLOAD_DONE', fileId });
767
+ }
768
+
769
+ /** @private */
770
+ _uint8ToBase64(uint8) {
771
+ let binary = '';
772
+ for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
773
+ if (typeof btoa !== 'undefined') return btoa(binary);
774
+ return Buffer.from(binary, 'binary').toString('base64');
775
+ }
776
+
777
+ /**
778
+ * Download a file from the server by chunks.
779
+ * @param {string} fileId
780
+ * @param {number} [startChunk=0]
781
+ */
782
+ subFile(fileId, startChunk = 0) {
783
+ this._sendRaw({ type: 'FILE_REQUEST', fileId, startChunk });
784
+ }
785
+
786
+ /**
787
+ * Resume a file download from saved progress.
788
+ * @param {string} fileId
789
+ */
790
+ resumeFile(fileId) {
791
+ const last = parseInt(this.storage.getItem(`dolphin_file_${fileId}`) || '-1');
792
+ this.subFile(fileId, last + 1);
793
+ }
794
+
795
+ /**
796
+ * Save download chunk progress.
797
+ * @param {string} fileId
798
+ * @param {number} chunkIndex
799
+ */
800
+ saveFileProgress(fileId, chunkIndex) {
801
+ this.storage.setItem(`dolphin_file_${fileId}`, chunkIndex.toString());
802
+ }
803
+
804
+ // ── Signaling ─────────────────────────────────────────────────────────────
805
+
806
+ /**
807
+ * @param {function(SignalMessage): void} handler
808
+ */
809
+ onSignal(handler) { this.signalHandlers.add(handler); }
810
+
811
+ /**
812
+ * @param {function(SignalMessage): void} handler
813
+ */
814
+ offSignal(handler) { this.signalHandlers.delete(handler); }
815
+
816
+ /**
817
+ * @param {function(FileMetadata): void} handler
818
+ */
819
+ onFileAvailable(handler) { this.fileHandlers.add(handler); }
820
+
821
+ /**
822
+ * @param {function(FileMetadata): void} handler
823
+ */
824
+ offFileAvailable(handler) { this.fileHandlers.delete(handler); }
825
+ }
826
+
827
+ // ─── Exports ──────────────────────────────────────────────────────────────────
828
+
829
+ if (typeof window !== 'undefined') {
830
+ window.DolphinClient = DolphinClient;
831
+ window.dolphin = new DolphinClient();
832
+ }
833
+
834
+ if (typeof module !== 'undefined' && module.exports) {
835
+ module.exports = { DolphinClient };
836
+ }
837
+
838
+ export { DolphinClient };