dolphin-server-modules 2.12.1 → 2.12.2

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.
package/scripts/client.js CHANGED
@@ -1,1003 +1,874 @@
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
- * @property {boolean} [autoBroadcast=false] — auto-publish non-GET API requests to realtime
56
- */
57
-
58
- // ─── APIHandler ───────────────────────────────────────────────────────────────
59
-
60
- class APIHandler {
61
- /** @param {DolphinClient} client */
62
- constructor(client) {
63
- this.client = client;
64
- return this._createProxy([]);
65
- }
66
-
67
- /** @private */
68
- _createProxy(pathParts) {
69
- const joined = pathParts.join('/');
70
-
71
- const target = (options) => this.request('GET', joined, null, options);
72
-
73
- target.get = (pathOrOptions, options) =>
74
- typeof pathOrOptions === 'string'
75
- ? this.request('GET', pathOrOptions, null, options)
76
- : this.request('GET', joined, null, pathOrOptions);
77
-
78
- target.post = (pathOrBody, bodyOrOptions, options) =>
79
- typeof pathOrBody === 'string'
80
- ? this.request('POST', pathOrBody, bodyOrOptions, options)
81
- : this.request('POST', joined, pathOrBody, bodyOrOptions);
82
-
83
- target.put = (pathOrBody, bodyOrOptions, options) =>
84
- typeof pathOrBody === 'string'
85
- ? this.request('PUT', pathOrBody, bodyOrOptions, options)
86
- : this.request('PUT', joined, pathOrBody, bodyOrOptions);
87
-
88
- target.patch = (pathOrBody, bodyOrOptions, options) =>
89
- typeof pathOrBody === 'string'
90
- ? this.request('PATCH', pathOrBody, bodyOrOptions, options)
91
- : this.request('PATCH', joined, pathOrBody, bodyOrOptions);
92
-
93
- target.del = (pathOrOptions, options) =>
94
- typeof pathOrOptions === 'string'
95
- ? this.request('DELETE', pathOrOptions, null, options)
96
- : this.request('DELETE', joined, null, pathOrOptions);
97
-
98
- target.request = (method, subPath, body, options) => {
99
- const finalPath = subPath
100
- ? `${joined}/${subPath.startsWith('/') ? subPath.slice(1) : subPath}`
101
- : joined;
102
- return this.request(method, finalPath, body, options);
103
- };
104
-
105
- const methods = ['get', 'post', 'put', 'patch', 'del', 'request'];
106
-
107
- return new Proxy(target, {
108
- get: (t, prop) => {
109
- if (typeof prop === 'string' && !methods.includes(prop)) {
110
- return this._createProxy([...pathParts, prop]);
111
- }
112
- return t[prop];
113
- }
114
- });
115
- }
116
-
117
- /**
118
- * Make an HTTP request with timeout + auto token refresh.
119
- * @param {string} method
120
- * @param {string} path
121
- * @param {any} [body]
122
- * @param {RequestInit} [options]
123
- * @param {boolean} [_isRetry=false] — internal: prevent infinite refresh loop
124
- * @returns {Promise<any>}
125
- */
126
- async request(method, path, body = null, options = {}, _isRetry = false) {
127
- const url = `${this.client.httpUrl}${path.startsWith('/') ? path : '/' + path}`;
128
-
129
- const controller = new AbortController();
130
- const timeoutId = setTimeout(
131
- () => controller.abort(),
132
- this.client.options.timeout
133
- );
134
-
135
- const headers = {
136
- 'Content-Type': 'application/json',
137
- ...(options.headers || {}),
138
- };
139
- if (this.client.accessToken) {
140
- headers['Authorization'] = `Bearer ${this.client.accessToken}`;
141
- }
142
-
143
- try {
144
- const response = await fetch(url, {
145
- method,
146
- headers,
147
- signal: controller.signal,
148
- ...(body ? { body: JSON.stringify(body) } : {}),
149
- ...options,
150
- });
151
-
152
- clearTimeout(timeoutId);
153
-
154
- // Auto-refresh: 401 + not a retry + autoRefreshToken enabled
155
- if (
156
- response.status === 401 &&
157
- !_isRetry &&
158
- this.client.options.autoRefreshToken
159
- ) {
160
- const refreshed = await this.client.auth._silentRefresh();
161
- if (refreshed) {
162
- return this.request(method, path, body, options, true);
163
- }
164
- }
165
-
166
- const contentType = response.headers.get('content-type') || '';
167
- const data = contentType.includes('application/json')
168
- ? await response.json()
169
- : await response.text();
170
-
171
- if (!response.ok) throw { status: response.status, data };
172
-
173
- // Auto-save Token for Hookless Auth
174
- if (data && data.accessToken) {
175
- this.client.setToken(data.accessToken);
176
- if (data.user) this.client.auth.user = data.user;
177
- }
178
-
179
- // Auto Realtime Broadcast (Dual-Execution)
180
- if (this.client.options.autoBroadcast && method !== 'GET') {
181
- const topic = path.startsWith('/') ? path.slice(1) : path;
182
- this.client.publish(topic, { method, payload: body, result: data });
183
- }
184
-
185
- return data;
186
-
187
- } catch (err) {
188
- clearTimeout(timeoutId);
189
- if (err.name === 'AbortError') {
190
- throw { status: 408, data: { error: 'Request timed out' } };
191
- }
192
- throw err;
193
- }
194
- }
195
- }
196
-
197
- // ─── AuthHandler ──────────────────────────────────────────────────────────────
198
-
199
- class AuthHandler {
200
- /** @param {DolphinClient} client */
201
- constructor(client) {
202
- this.client = client;
203
- /** @type {any|null} */
204
- this.user = null;
205
- this._refreshing = false;
206
- }
207
-
208
- /**
209
- * Login with email + password.
210
- * @param {string} email
211
- * @param {string} password
212
- */
213
- async login(email, password) {
214
- const res = await this.client.api.post('/api/auth/login', { email, password });
215
- if (res.accessToken) {
216
- this.client.setToken(res.accessToken);
217
- this.user = res.user || null;
218
- }
219
- return res;
220
- }
221
-
222
- /**
223
- * Register a new account.
224
- * @param {{ email: string, password: string, [key: string]: any }} data
225
- */
226
- async register(data) {
227
- return this.client.api.post('/api/auth/register', data);
228
- }
229
-
230
- /** Get current user profile. */
231
- async me() {
232
- const res = await this.client.api.get('/api/auth/me');
233
- if (res.success) this.user = res.data;
234
- return res;
235
- }
236
-
237
- /** Logout and clear token. */
238
- async logout() {
239
- try { await this.client.api.post('/api/auth/logout'); } catch {}
240
- this.client.setToken(null);
241
- this.user = null;
242
- }
243
-
244
- /**
245
- * Manually refresh the access token using the httpOnly refresh-token cookie.
246
- * Called automatically on 401 if autoRefreshToken is enabled.
247
- * @returns {Promise<boolean>} true if refresh succeeded
248
- */
249
- async refresh() {
250
- return this._silentRefresh();
251
- }
252
-
253
- /** @private */
254
- async _silentRefresh() {
255
- if (this._refreshing) return false;
256
- this._refreshing = true;
257
- try {
258
- const res = await this.client.api.post('/api/auth/refresh', null, {}, true);
259
- if (res.accessToken) {
260
- this.client.setToken(res.accessToken);
261
- return true;
262
- }
263
- return false;
264
- } catch {
265
- this.client.setToken(null);
266
- return false;
267
- } finally {
268
- this._refreshing = false;
269
- }
270
- }
271
-
272
- /**
273
- * Verify a 2FA TOTP code after login.
274
- * @param {string} code — 6-digit TOTP code
275
- * @param {string} [email] — email (if not already in user)
276
- */
277
- async verify2FA(code, email) {
278
- const payload = {
279
- code,
280
- email: email || this.user?.email,
281
- };
282
- const res = await this.client.api.post('/api/auth/2fa/verify', payload);
283
- if (res.accessToken) {
284
- this.client.setToken(res.accessToken);
285
- if (res.user) this.user = res.user;
286
- }
287
- return res;
288
- }
289
-
290
- /**
291
- * Enable 2FA returns QR code URL and secret.
292
- */
293
- async enable2FA() {
294
- return this.client.api.post('/api/auth/2fa/enable');
295
- }
296
-
297
- /**
298
- * Disable 2FA.
299
- * @param {string} code — current TOTP code to confirm
300
- */
301
- async disable2FA(code) {
302
- return this.client.api.post('/api/auth/2fa/disable', { code });
303
- }
304
-
305
- /**
306
- * Request a password reset email.
307
- * @param {string} email
308
- */
309
- async forgotPassword(email) {
310
- return this.client.api.post('/api/auth/forgot-password', { email });
311
- }
312
-
313
- /**
314
- * Reset password using the token from email.
315
- * @param {string} token
316
- * @param {string} newPassword
317
- */
318
- async resetPassword(token, newPassword) {
319
- return this.client.api.post('/api/auth/reset-password', { token, newPassword });
320
- }
321
- }
322
-
323
- // ─── DolphinStore ─────────────────────────────────────────────────────────────
324
-
325
- /**
326
- * Reactive state sync — auto-fetches collections and keeps them live
327
- * via WebSocket pub/sub. Works with React useSyncExternalStore.
328
- */
329
- class DolphinStore {
330
- /** @param {DolphinClient} client */
331
- constructor(client) {
332
- this.client = client;
333
- /** @type {Map<string, any>} */
334
- this.data = new Map();
335
- /** @type {Set<function()>} */
336
- this.listeners = new Set();
337
- /** @type {Set<string>} */
338
- this.subscribed = new Set();
339
-
340
- return new Proxy(this, {
341
- get: (target, prop) => {
342
- if (prop in target) return target[prop];
343
- if (typeof prop === 'string') return this._getCollection(prop);
344
- }
345
- });
346
- }
347
-
348
- /** @private */
349
- _getCollection(name) {
350
- if (!this.data.has(name)) {
351
- const collection = {
352
- _rawItems: [],
353
- items: [],
354
- loading: true,
355
- error: null,
356
- success: false,
357
- _filter: null,
358
- _sort: null,
359
-
360
- where: (fn) => {
361
- collection._filter = fn;
362
- this._applyTransform(collection);
363
- return collection;
364
- },
365
- orderBy: (key, direction = 'asc') => {
366
- collection._sort = { key, direction };
367
- this._applyTransform(collection);
368
- return collection;
369
- },
370
- reset: () => {
371
- collection._filter = null;
372
- collection._sort = null;
373
- this._applyTransform(collection);
374
- return collection;
375
- },
376
- };
377
-
378
- this.data.set(name, collection);
379
- this._fetchAndSync(name);
380
- }
381
- return this.data.get(name);
382
- }
383
-
384
- /** @private */
385
- async _fetchAndSync(name) {
386
- const state = this.data.get(name);
387
- try {
388
- const res = await this.client.api.get(`/${name.toLowerCase()}`);
389
- state._rawItems = Array.isArray(res) ? res : (res.data || []);
390
- state.loading = false;
391
- state.success = true;
392
- state.error = null;
393
- this._applyTransform(state);
394
-
395
- if (!this.subscribed.has(name)) {
396
- this.client.subscribe(`db:sync/${name.toLowerCase()}`, (update) => {
397
- this._handleRemoteUpdate(name, update);
398
- });
399
- this.subscribed.add(name);
400
- }
401
- } catch (e) {
402
- state.loading = false;
403
- state.success = false;
404
- state.error = e.data?.error || e.message || 'Fetch failed';
405
- this._notify();
406
- }
407
- }
408
-
409
- /** @private */
410
- _applyTransform(state) {
411
- let result = [...state._rawItems];
412
- if (state._filter) result = result.filter(state._filter);
413
- if (state._sort) {
414
- const { key, direction } = state._sort;
415
- result.sort((a, b) => {
416
- if (a[key] === b[key]) return 0;
417
- return (a[key] > b[key] ? 1 : -1) * (direction === 'asc' ? 1 : -1);
418
- });
419
- }
420
- state.items = result;
421
- this._notify();
422
- }
423
-
424
- /** @private */
425
- _handleRemoteUpdate(collection, update) {
426
- const state = this.data.get(collection);
427
- if (!state) return;
428
- const { type, data } = update;
429
- let items = state._rawItems;
430
-
431
- if (type === 'create') {
432
- items = [...items, data];
433
- } else if (type === 'update') {
434
- items = items.map(i => (i.id === data.id || i._id === data._id) ? { ...i, ...data } : i);
435
- } else if (type === 'delete') {
436
- items = items.filter(i => {
437
- if (data.id != null && i.id === data.id) return false;
438
- if (data._id != null && i._id === data._id) return false;
439
- return true;
440
- });
441
- }
442
-
443
- state._rawItems = items;
444
- this._applyTransform(state);
445
- }
446
-
447
- /** Subscribe for React useSyncExternalStore */
448
- subscribe(listener) {
449
- this.listeners.add(listener);
450
- return () => this.listeners.delete(listener);
451
- }
452
-
453
- /** @param {string} collection */
454
- getSnapshot(collection) {
455
- return this.data.get(collection) || { items: [], loading: false, error: null, success: false };
456
- }
457
-
458
- /** @private */
459
- _notify() {
460
- this.listeners.forEach(l => l());
461
- }
462
- }
463
-
464
- // ─── DolphinClient ────────────────────────────────────────────────────────────
465
-
466
- class DolphinClient {
467
- /**
468
- * @param {string} [url]
469
- * @param {string} [deviceId]
470
- * @param {DolphinClientOptions} [options]
471
- */
472
- constructor(url = '', deviceId = '', options = {}) {
473
- if (!url && typeof window !== 'undefined') url = window.location.host;
474
-
475
- let protocol = 'http:';
476
- if (url.startsWith('https://')) protocol = 'https:';
477
- else if (url.startsWith('http://')) protocol = 'http:';
478
- else if (typeof window !== 'undefined') protocol = window.location.protocol;
479
-
480
- this.host = (url || 'localhost').replace(/\/$/, '').replace(/^https?:\/\//, '');
481
- this.httpUrl = `${protocol}//${this.host}`;
482
- this.deviceId = deviceId || 'web_' + Math.random().toString(36).substr(2, 8);
483
-
484
- /** @type {DolphinClientOptions} */
485
- this.options = {
486
- timeout: 15000,
487
- chunkSize: 65536, // 64 KB
488
- maxReconnect: 5,
489
- autoRefreshToken: true,
490
- autoBroadcast: false,
491
- ...options,
492
- };
493
-
494
- /** @type {WebSocket|null} */
495
- this.socket = null;
496
-
497
- // Storage polyfill (Supports Web localStorage & Mobile AsyncStorage)
498
- this.storage = this.options.storage || (typeof localStorage !== 'undefined' ? localStorage : {
499
- getItem: () => null,
500
- setItem: () => {},
501
- removeItem: () => {},
502
- });
503
-
504
- /** @type {string|null} */
505
- this.accessToken = null;
506
-
507
- // Handle both Sync (Web) and Async (React Native) storage safely
508
- try {
509
- const tokenVal = this.storage.getItem('dolphin_token');
510
- if (tokenVal && typeof tokenVal.then === 'function') {
511
- tokenVal.then(t => this.accessToken = t).catch(() => {});
512
- } else {
513
- this.accessToken = tokenVal;
514
- }
515
- } catch (e) {
516
- this.accessToken = null;
517
- }
518
-
519
- // Sub-handlers
520
- this.api = new APIHandler(this);
521
- this.auth = new AuthHandler(this);
522
- this.store = new DolphinStore(this);
523
-
524
- /** @type {Map<string, Set<TopicCallback>>} */
525
- this.handlers = new Map();
526
- /** @type {Set<function(SignalMessage): void>} */
527
- this.signalHandlers = new Set();
528
- /** @type {Set<function(FileMetadata): void>} */
529
- this.fileHandlers = new Set();
530
-
531
- /** @type {Array<string>} — offline message queue */
532
- this._offlineQueue = [];
533
-
534
- this.reconnectAttempts = 0;
535
-
536
- if (typeof window !== 'undefined') {
537
- if (document.readyState === 'loading') {
538
- document.addEventListener('DOMContentLoaded', () => this._initDOMBinding());
539
- } else {
540
- this._initDOMBinding();
541
- }
542
- }
543
- }
544
-
545
- /** Save or clear the access token */
546
- setToken(token) {
547
- this.accessToken = token;
548
- try {
549
- if (token) this.storage.setItem('dolphin_token', token);
550
- else this.storage.removeItem('dolphin_token');
551
- } catch (e) {}
552
- }
553
-
554
- // ── WebSocket ─────────────────────────────────────────────────────────────
555
-
556
- /** Connect to the Dolphin realtime server */
557
- async connect() {
558
- return new Promise((resolve, reject) => {
559
- const protocol = this.httpUrl.startsWith('https') ? 'wss:' : 'ws:';
560
- const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
561
-
562
- console.log(`[Dolphin] Connecting to ${wsUrl}...`);
563
- this.socket = new WebSocket(wsUrl);
564
-
565
- this.socket.onopen = () => {
566
- console.log(`[Dolphin] Connected as "${this.deviceId}" 🐬`);
567
- this.reconnectAttempts = 0;
568
- this._flushOfflineQueue();
569
- resolve();
570
- };
571
- this.socket.onmessage = (ev) => this._handleMessage(ev.data);
572
- this.socket.onclose = () => {
573
- console.warn('[Dolphin] Connection closed');
574
- this._maybeReconnect();
575
- };
576
- this.socket.onerror = (err) => {
577
- console.error('[Dolphin] WebSocket error:', err);
578
- reject(err);
579
- };
580
- });
581
- }
582
-
583
- /** Disconnect cleanly */
584
- disconnect() {
585
- if (this.socket) {
586
- this.socket.onclose = null; // prevent auto-reconnect
587
- this.socket.close();
588
- this.socket = null;
589
- }
590
- }
591
-
592
- /** @private */
593
- _handleMessage(data) {
594
- try {
595
- const msg = JSON.parse(data);
596
-
597
- // Signaling
598
- if (msg.type && msg.from && (msg.to === this.deviceId || msg.to === 'all')) {
599
- if (msg.msgId && msg.type !== 'ACK') this._sendAck(msg.from, msg.msgId);
600
- this.signalHandlers.forEach(h => h(msg));
601
- }
602
-
603
- // File events
604
- if (msg.type === 'FILE_AVAILABLE') {
605
- this.fileHandlers.forEach(h => h(msg));
606
- }
607
- if (msg.type === 'FILE_CHUNK') {
608
- this.saveFileProgress(msg.fileId, msg.chunkIndex);
609
- this._dispatch('file:chunk', msg);
610
- this._dispatch(`file:chunk/${msg.fileId}`, msg);
611
- }
612
- if (msg.type === 'FILE_UPLOAD_ACK') {
613
- this._dispatch(`file:upload:ack/${msg.fileId}`, msg);
614
- }
615
-
616
- // Pull response
617
- if (msg.type === 'PULL_RESPONSE') {
618
- this._dispatch('pull:response', msg.payload, msg.topic);
619
- this._dispatch(`pull:response/${msg.topic}`, msg.payload, msg.topic);
620
- }
621
-
622
- // Pub/Sub
623
- if (msg.topic && msg.payload !== undefined) {
624
- this.handlers.forEach((cbs, pattern) => {
625
- if (this._matchTopic(pattern, msg.topic)) {
626
- cbs.forEach(cb => cb(msg.payload, msg.topic));
627
- }
628
- });
629
- }
630
- } catch {
631
- this._dispatch('raw', data);
632
- }
633
- }
634
-
635
- /** @private */
636
- _dispatch(pattern, payload, topic) {
637
- const cbs = this.handlers.get(pattern);
638
- if (cbs) cbs.forEach(cb => cb(payload, topic || pattern));
639
- }
640
-
641
- /** @private */
642
- _sendRaw(msg) {
643
- const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
644
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
645
- this.socket.send(str);
646
- } else {
647
- // Buffer for offline queue (max 100 messages)
648
- if (this._offlineQueue.length < 100) this._offlineQueue.push(str);
649
- }
650
- }
651
-
652
- /** Flush buffered messages after reconnect @private */
653
- _flushOfflineQueue() {
654
- while (this._offlineQueue.length > 0) {
655
- const msg = this._offlineQueue.shift();
656
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
657
- this.socket.send(msg);
658
- }
659
- }
660
- }
661
-
662
- /** @private */
663
- _sendAck(to, msgId) {
664
- this._sendRaw({ type: 'ACK', from: this.deviceId, to, data: { ackId: msgId }, timestamp: Date.now() });
665
- }
666
-
667
- /** MQTT wildcard topic matching @private */
668
- _matchTopic(pattern, topic) {
669
- if (pattern === topic || pattern === '#') return true;
670
- const pp = pattern.split('/');
671
- const tp = topic.split('/');
672
- if (pp.length !== tp.length && !pattern.includes('#')) return false;
673
- for (let i = 0; i < pp.length; i++) {
674
- if (pp[i] === '#') return true;
675
- if (pp[i] !== '+' && pp[i] !== tp[i]) return false;
676
- }
677
- return pp.length === tp.length;
678
- }
679
-
680
- /** @private */
681
- _maybeReconnect() {
682
- if (this.reconnectAttempts < this.options.maxReconnect) {
683
- this.reconnectAttempts++;
684
- const delay = Math.pow(2, this.reconnectAttempts) * 1000;
685
- console.log(`[Dolphin] Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts})...`);
686
- setTimeout(() => this.connect().catch(() => {}), delay);
687
- } else {
688
- console.error('[Dolphin] Max reconnect attempts reached.');
689
- }
690
- }
691
-
692
- // ── Pub/Sub ───────────────────────────────────────────────────────────────
693
-
694
- /**
695
- * Subscribe to a topic (MQTT wildcards supported: + and #).
696
- * @param {string} topic
697
- * @param {TopicCallback} callback
698
- */
699
- subscribe(topic, callback) {
700
- if (!this.handlers.has(topic)) {
701
- this.handlers.set(topic, new Set());
702
- this._sendRaw({ type: 'sub', topic });
703
- }
704
- this.handlers.get(topic).add(callback);
705
- }
706
-
707
- /**
708
- * Unsubscribe from a topic.
709
- * @param {string} topic
710
- * @param {TopicCallback} callback
711
- */
712
- unsubscribe(topic, callback) {
713
- if (this.handlers.has(topic)) {
714
- const cbs = this.handlers.get(topic);
715
- cbs.delete(callback);
716
- if (cbs.size === 0) {
717
- this.handlers.delete(topic);
718
- this._sendRaw({ type: 'unsub', topic });
719
- }
720
- }
721
- }
722
-
723
- /**
724
- * Publish a message to a topic. Queued if offline.
725
- * @param {string} topic
726
- * @param {any} payload
727
- */
728
- publish(topic, payload) {
729
- this._sendRaw({ topic, payload });
730
- }
731
-
732
- /**
733
- * High-frequency data push (IoT sensors).
734
- * @param {string} topic
735
- * @param {any} payload
736
- */
737
- pubPush(topic, payload) {
738
- this._sendRaw({ type: 'pub', topic, payload });
739
- }
740
-
741
- /**
742
- * Request historical data from a topic.
743
- * @param {string} topic
744
- * @param {number} [count=10]
745
- */
746
- subPull(topic, count = 10) {
747
- this._sendRaw({ type: 'PULL_REQUEST', topic, count });
748
- }
749
-
750
- // ── File Transfer ─────────────────────────────────────────────────────────
751
-
752
- /**
753
- * Upload a file to the server in chunks.
754
- * @param {string} fileId
755
- * @param {Blob|ArrayBuffer|Uint8Array} fileData
756
- * @param {string} [filename]
757
- * @param {function(number): void} [onProgress] — progress callback (0-100)
758
- * @returns {Promise<void>}
759
- */
760
- async pubFile(fileId, fileData, filename = '', onProgress) {
761
- let buffer;
762
- if (fileData instanceof Blob) {
763
- buffer = await fileData.arrayBuffer();
764
- } else if (fileData instanceof ArrayBuffer) {
765
- buffer = fileData;
766
- } else {
767
- buffer = fileData.buffer || fileData;
768
- }
769
-
770
- const bytes = new Uint8Array(buffer);
771
- const chunkSize = this.options.chunkSize;
772
- const totalChunks = Math.ceil(bytes.length / chunkSize);
773
-
774
- // Send file metadata first
775
- this._sendRaw({
776
- type: 'FILE_UPLOAD_START',
777
- fileId,
778
- name: filename,
779
- size: bytes.length,
780
- totalChunks,
781
- chunkSize,
782
- });
783
-
784
- for (let i = 0; i < totalChunks; i++) {
785
- const chunk = bytes.slice(i * chunkSize, (i + 1) * chunkSize);
786
- const b64 = this._uint8ToBase64(chunk);
787
-
788
- this._sendRaw({
789
- type: 'FILE_UPLOAD_CHUNK',
790
- fileId,
791
- chunkIndex: i,
792
- totalChunks,
793
- data: b64,
794
- });
795
-
796
- if (onProgress) onProgress(Math.round(((i + 1) / totalChunks) * 100));
797
-
798
- // Small yield to prevent blocking
799
- if (i % 10 === 0) await new Promise(r => setTimeout(r, 0));
800
- }
801
-
802
- this._sendRaw({ type: 'FILE_UPLOAD_DONE', fileId });
803
- }
804
-
805
- /** @private */
806
- _uint8ToBase64(uint8) {
807
- let binary = '';
808
- for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
809
- if (typeof btoa !== 'undefined') return btoa(binary);
810
- return Buffer.from(binary, 'binary').toString('base64');
811
- }
812
-
813
- /**
814
- * Download a file from the server by chunks.
815
- * @param {string} fileId
816
- * @param {number} [startChunk=0]
817
- */
818
- subFile(fileId, startChunk = 0) {
819
- this._sendRaw({ type: 'FILE_REQUEST', fileId, startChunk });
820
- }
821
-
822
- /**
823
- * Resume a file download from saved progress.
824
- * @param {string} fileId
825
- */
826
- resumeFile(fileId) {
827
- const last = parseInt(this.storage.getItem(`dolphin_file_${fileId}`) || '-1');
828
- this.subFile(fileId, last + 1);
829
- }
830
-
831
- /**
832
- * Save download chunk progress.
833
- * @param {string} fileId
834
- * @param {number} chunkIndex
835
- */
836
- saveFileProgress(fileId, chunkIndex) {
837
- this.storage.setItem(`dolphin_file_${fileId}`, chunkIndex.toString());
838
- }
839
-
840
- // ── Signaling ─────────────────────────────────────────────────────────────
841
-
842
- /**
843
- * @param {function(SignalMessage): void} handler
844
- */
845
- onSignal(handler) { this.signalHandlers.add(handler); }
846
-
847
- /**
848
- * @param {function(SignalMessage): void} handler
849
- */
850
- offSignal(handler) { this.signalHandlers.delete(handler); }
851
-
852
- /**
853
- * @param {function(FileMetadata): void} handler
854
- */
855
- onFileAvailable(handler) { this.fileHandlers.add(handler); }
856
-
857
- /**
858
- * @param {function(FileMetadata): void} handler
859
- */
860
- offFileAvailable(handler) { this.fileHandlers.delete(handler); }
861
-
862
- // ── DOM Binding (Hookless Realtime) ───────────────────────────────────────
863
-
864
- /** @private */
865
- _initDOMBinding() {
866
- if (this._domInitialized) return;
867
- this._domInitialized = true;
868
-
869
- // 1. Listen for inputs
870
- document.addEventListener('input', (e) => {
871
- if (!e.target || !e.target.getAttribute) return;
872
- const topic = e.target.getAttribute('data-rt-push');
873
- if (topic) {
874
- const payload = { name: e.target.name, value: e.target.value };
875
- this.pubPush(topic, payload);
876
- }
877
- });
878
-
879
- // 2. Listen for form submits (RT + API)
880
- document.addEventListener('submit', async (e) => {
881
- if (!e.target || !e.target.getAttribute) return;
882
-
883
- const rtTopic = e.target.getAttribute('data-rt-submit');
884
- const apiTarget = e.target.getAttribute('data-api-submit');
885
-
886
- if (rtTopic || apiTarget) {
887
- e.preventDefault();
888
- const formData = new FormData(e.target);
889
- const data = Object.fromEntries(formData.entries());
890
-
891
- if (rtTopic) {
892
- this.publish(rtTopic, data);
893
- } else if (apiTarget) {
894
- const parts = apiTarget.trim().split(' ');
895
- const method = parts.length > 1 ? parts[0].toUpperCase() : 'POST';
896
- const path = parts.length > 1 ? parts[1] : parts[0];
897
- try {
898
- const result = await this.api.request(method, path, data);
899
- const resultBind = e.target.getAttribute('data-api-result');
900
- if (resultBind) this._updateDOM(resultBind, result);
901
-
902
- // Auto Navigation (Hookless Routing)
903
- const redirect = e.target.getAttribute('data-api-redirect');
904
- if (redirect) window.location.href = redirect;
905
- if (e.target.hasAttribute('data-api-reload')) window.location.reload();
906
- } catch (err) {
907
- console.error('[Dolphin] API Submit Error:', err);
908
- }
909
- }
910
- }
911
- });
912
-
913
- // 3. Listen for clicks (RT + API)
914
- document.addEventListener('click', async (e) => {
915
- if (!e.target || !e.target.closest) return;
916
-
917
- const rtBtn = e.target.closest('[data-rt-click]');
918
- const apiBtn = e.target.closest('[data-api-click]');
919
-
920
- if (rtBtn) {
921
- const topic = rtBtn.getAttribute('data-rt-click');
922
- const actionData = rtBtn.getAttribute('data-rt-payload');
923
- const payload = actionData ? JSON.parse(actionData) : {};
924
- this.publish(topic, payload);
925
- } else if (apiBtn) {
926
- const apiTarget = apiBtn.getAttribute('data-api-click');
927
- const actionData = apiBtn.getAttribute('data-api-payload');
928
- const payload = actionData ? JSON.parse(actionData) : null;
929
- const parts = apiTarget.trim().split(' ');
930
- const method = parts.length > 1 ? parts[0].toUpperCase() : 'POST';
931
- const path = parts.length > 1 ? parts[1] : parts[0];
932
- try {
933
- const result = await this.api.request(method, path, payload);
934
- const resultBind = apiBtn.getAttribute('data-api-result');
935
- if (resultBind) this._updateDOM(resultBind, result);
936
-
937
- // Auto Navigation (Hookless Routing)
938
- const redirect = apiBtn.getAttribute('data-api-redirect');
939
- if (redirect) window.location.href = redirect;
940
- if (apiBtn.hasAttribute('data-api-reload')) window.location.reload();
941
- } catch (err) {
942
- console.error('[Dolphin] API Click Error:', err);
943
- }
944
- }
945
- });
946
-
947
- // 4. Update DOM when RT data arrives
948
- // Note: Subscribe to all topics ('#') to auto-update DOM bindings
949
- this.subscribe('#', (payload, topic) => {
950
- this._updateDOM(topic, payload);
951
- });
952
-
953
- // 5. Auto-fetch API GET bindings
954
- this._scanAndFetchAPIBinds();
955
- }
956
-
957
- /** @private */
958
- async _scanAndFetchAPIBinds() {
959
- if (typeof document === 'undefined') return;
960
- const elements = document.querySelectorAll('[data-api-get]');
961
- for (const el of elements) {
962
- const path = el.getAttribute('data-api-get');
963
- if (!path) continue;
964
- try {
965
- const result = await this.api.get(path);
966
- if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
967
- el.value = typeof result === 'object' ? (result.value !== undefined ? result.value : '') : result;
968
- } else {
969
- el.innerHTML = typeof result === 'object' ? (result.html || result.text || JSON.stringify(result)) : result;
970
- }
971
- } catch(e) {
972
- console.error('[Dolphin] API Get Error:', e);
973
- }
974
- }
975
- }
976
-
977
- /** @private */
978
- _updateDOM(topic, payload) {
979
- if (typeof document === 'undefined') return;
980
- const elements = document.querySelectorAll(`[data-rt-bind="${topic}"]`);
981
- elements.forEach(el => {
982
- if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
983
- el.value = typeof payload === 'object' ? (payload.value !== undefined ? payload.value : '') : payload;
984
- } else {
985
- el.innerHTML = typeof payload === 'object' ? (payload.html || payload.text || JSON.stringify(payload)) : payload;
986
- }
987
- });
988
- }
989
- }
990
-
991
- // ─── Exports ──────────────────────────────────────────────────────────────────
992
-
993
- if (typeof window !== 'undefined') {
994
- window.DolphinClient = DolphinClient;
995
- window.dolphin = new DolphinClient();
996
- }
997
-
998
- if (typeof module !== 'undefined' && module.exports) {
999
- module.exports = { DolphinClient };
1000
- }
1001
-
1002
- // Note: No top-level `export` here so that this file can be loaded
1003
- // via classic <script src="..."> in browsers / React without "Unexpected token 'export'"
1
+ "use strict";
2
+ var DolphinModule = (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/client/index.ts
22
+ var index_exports = {};
23
+ __export(index_exports, {
24
+ DolphinClient: () => DolphinClient
25
+ });
26
+
27
+ // src/client/api.ts
28
+ var APIHandler = class {
29
+ /** @param {DolphinClient} client */
30
+ constructor(client) {
31
+ this.client = client;
32
+ return this._createProxy([]);
33
+ }
34
+ /** @private */
35
+ _createProxy(pathParts) {
36
+ const joined = pathParts.join("/");
37
+ const target = (options) => this.request("GET", joined, null, options);
38
+ target.get = (pathOrOptions, options) => typeof pathOrOptions === "string" ? this.request("GET", pathOrOptions, null, options) : this.request("GET", joined, null, pathOrOptions);
39
+ target.post = (pathOrBody, bodyOrOptions, options) => typeof pathOrBody === "string" ? this.request("POST", pathOrBody, bodyOrOptions, options) : this.request("POST", joined, pathOrBody, bodyOrOptions);
40
+ target.put = (pathOrBody, bodyOrOptions, options) => typeof pathOrBody === "string" ? this.request("PUT", pathOrBody, bodyOrOptions, options) : this.request("PUT", joined, pathOrBody, bodyOrOptions);
41
+ target.patch = (pathOrBody, bodyOrOptions, options) => typeof pathOrBody === "string" ? this.request("PATCH", pathOrBody, bodyOrOptions, options) : this.request("PATCH", joined, pathOrBody, bodyOrOptions);
42
+ target.del = (pathOrOptions, options) => typeof pathOrOptions === "string" ? this.request("DELETE", pathOrOptions, null, options) : this.request("DELETE", joined, null, pathOrOptions);
43
+ target.request = (method, subPath, body, options) => {
44
+ const finalPath = subPath ? `${joined}/${subPath.startsWith("/") ? subPath.slice(1) : subPath}` : joined;
45
+ return this.request(method, finalPath, body, options);
46
+ };
47
+ const methods = ["get", "post", "put", "patch", "del", "request"];
48
+ return new Proxy(target, {
49
+ get: (t, prop) => {
50
+ if (typeof prop === "string" && !methods.includes(prop)) {
51
+ return this._createProxy([...pathParts, prop]);
52
+ }
53
+ return t[prop];
54
+ }
55
+ });
56
+ }
57
+ /**
58
+ * Make an HTTP request with timeout + auto token refresh.
59
+ * @param {string} method
60
+ * @param {string} path
61
+ * @param {any} [body]
62
+ * @param {RequestInit} [options]
63
+ * @param {boolean} [_isRetry=false] — internal: prevent infinite refresh loop
64
+ * @returns {Promise<any>}
65
+ */
66
+ async request(method, path, body = null, options = {}) {
67
+ const _isRetry = options._isRetry === true;
68
+ const url = `${this.client.httpUrl}${path.startsWith("/") ? path : "/" + path}`;
69
+ const controller = new AbortController();
70
+ const timeoutId = setTimeout(
71
+ () => controller.abort(),
72
+ this.client.options.timeout
73
+ );
74
+ const headers = {
75
+ "Content-Type": "application/json",
76
+ ...options.headers || {}
77
+ };
78
+ if (this.client.accessToken) {
79
+ headers["Authorization"] = `Bearer ${this.client.accessToken}`;
80
+ }
81
+ const fetchOptions = { ...options };
82
+ delete fetchOptions._isRetry;
83
+ try {
84
+ const response = await fetch(url, {
85
+ method,
86
+ headers,
87
+ signal: controller.signal,
88
+ ...body ? { body: JSON.stringify(body) } : {},
89
+ ...fetchOptions
90
+ });
91
+ clearTimeout(timeoutId);
92
+ if (response.status === 401 && !_isRetry && this.client.options.autoRefreshToken) {
93
+ const refreshed = await this.client.auth._silentRefresh();
94
+ if (refreshed) {
95
+ return this.request(method, path, body, { ...options, _isRetry: true });
96
+ }
97
+ }
98
+ const contentType = response.headers.get("content-type") || "";
99
+ const data = contentType.includes("application/json") ? await response.json() : await response.text();
100
+ if (!response.ok) throw { status: response.status, data };
101
+ if (data && typeof data === "object") {
102
+ if (data.accessToken) {
103
+ this.client.setToken(data.accessToken);
104
+ if (data.user) this.client.auth.user = data.user;
105
+ }
106
+ }
107
+ if (this.client.options.autoBroadcast && ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase())) {
108
+ const cleanPath = path.startsWith("/") ? path.substring(1) : path;
109
+ this.client.publish(cleanPath, { method: method.toUpperCase(), payload: body, result: data });
110
+ }
111
+ return data;
112
+ } catch (err) {
113
+ clearTimeout(timeoutId);
114
+ if (err.name === "AbortError") {
115
+ throw { status: 408, data: { error: "Request timed out" } };
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+ };
121
+
122
+ // src/client/auth.ts
123
+ var AuthHandler = class {
124
+ /** @param {DolphinClient} client */
125
+ constructor(client) {
126
+ this.client = client;
127
+ this.user = null;
128
+ this._refreshing = false;
129
+ }
130
+ /**
131
+ * Login with email + password.
132
+ * @param {string} email
133
+ * @param {string} password
134
+ */
135
+ async login(email, password) {
136
+ const res = await this.client.api.post("/api/auth/login", { email, password });
137
+ if (res.accessToken) {
138
+ this.client.setToken(res.accessToken);
139
+ this.user = res.user || null;
140
+ }
141
+ return res;
142
+ }
143
+ /**
144
+ * Register a new account.
145
+ * @param {{ email: string, password: string, [key: string]: any }} data
146
+ */
147
+ async register(data) {
148
+ return this.client.api.post("/api/auth/register", data);
149
+ }
150
+ /** Get current user profile. */
151
+ async me() {
152
+ const res = await this.client.api.get("/api/auth/me");
153
+ if (res.success) this.user = res.data;
154
+ return res;
155
+ }
156
+ /** Logout and clear token. */
157
+ async logout() {
158
+ try {
159
+ await this.client.api.post("/api/auth/logout");
160
+ } catch {
161
+ }
162
+ this.client.setToken(null);
163
+ this.user = null;
164
+ }
165
+ /**
166
+ * Manually refresh the access token using the httpOnly refresh-token cookie.
167
+ * Called automatically on 401 if autoRefreshToken is enabled.
168
+ * @returns {Promise<boolean>} — true if refresh succeeded
169
+ */
170
+ async refresh() {
171
+ return this._silentRefresh();
172
+ }
173
+ /** @private */
174
+ async _silentRefresh() {
175
+ if (this._refreshing) return false;
176
+ this._refreshing = true;
177
+ try {
178
+ const res = await this.client.api.post("/api/auth/refresh", null, { _isRetry: true });
179
+ if (res.accessToken) {
180
+ this.client.setToken(res.accessToken);
181
+ return true;
182
+ }
183
+ return false;
184
+ } catch {
185
+ this.client.setToken(null);
186
+ return false;
187
+ } finally {
188
+ this._refreshing = false;
189
+ }
190
+ }
191
+ /**
192
+ * Verify a 2FA TOTP code after login.
193
+ * @param {string} code — 6-digit TOTP code
194
+ * @param {string} [email] — email (if not already in user)
195
+ */
196
+ async verify2FA(code, email) {
197
+ const payload = {
198
+ code,
199
+ email: email || this.user?.email
200
+ };
201
+ const res = await this.client.api.post("/api/auth/2fa/verify", payload);
202
+ if (res.accessToken) {
203
+ this.client.setToken(res.accessToken);
204
+ if (res.user) this.user = res.user;
205
+ }
206
+ return res;
207
+ }
208
+ /**
209
+ * Enable 2FA returns QR code URL and secret.
210
+ */
211
+ async enable2FA() {
212
+ return this.client.api.post("/api/auth/2fa/enable");
213
+ }
214
+ /**
215
+ * Disable 2FA.
216
+ * @param {string} code — current TOTP code to confirm
217
+ */
218
+ async disable2FA(code) {
219
+ return this.client.api.post("/api/auth/2fa/disable", { code });
220
+ }
221
+ /**
222
+ * Request a password reset email.
223
+ * @param {string} email
224
+ */
225
+ async forgotPassword(email) {
226
+ return this.client.api.post("/api/auth/forgot-password", { email });
227
+ }
228
+ /**
229
+ * Reset password using the token from email.
230
+ * @param {string} token
231
+ * @param {string} newPassword
232
+ */
233
+ async resetPassword(token, newPassword) {
234
+ return this.client.api.post("/api/auth/reset-password", { token, newPassword });
235
+ }
236
+ };
237
+
238
+ // src/client/store.ts
239
+ var DolphinStore = class {
240
+ /** @param {DolphinClient} client */
241
+ constructor(client) {
242
+ this.client = client;
243
+ this.data = /* @__PURE__ */ new Map();
244
+ this.listeners = /* @__PURE__ */ new Set();
245
+ this.subscribed = /* @__PURE__ */ new Set();
246
+ return new Proxy(this, {
247
+ get: (target, prop) => {
248
+ if (prop in target) return target[prop];
249
+ if (typeof prop === "string") return this._getCollection(prop);
250
+ }
251
+ });
252
+ }
253
+ /** @private */
254
+ _getCollection(name) {
255
+ if (!this.data.has(name)) {
256
+ const collection = {
257
+ _rawItems: [],
258
+ items: [],
259
+ loading: true,
260
+ error: null,
261
+ success: false,
262
+ _filter: null,
263
+ _sort: null,
264
+ where: (fn) => {
265
+ collection._filter = fn;
266
+ this._applyTransform(collection);
267
+ return collection;
268
+ },
269
+ orderBy: (key, direction = "asc") => {
270
+ collection._sort = { key, direction };
271
+ this._applyTransform(collection);
272
+ return collection;
273
+ },
274
+ reset: () => {
275
+ collection._filter = null;
276
+ collection._sort = null;
277
+ this._applyTransform(collection);
278
+ return collection;
279
+ }
280
+ };
281
+ this.data.set(name, collection);
282
+ this._fetchAndSync(name);
283
+ }
284
+ return this.data.get(name);
285
+ }
286
+ /** @private */
287
+ async _fetchAndSync(name) {
288
+ const state = this.data.get(name);
289
+ try {
290
+ const res = await this.client.api.get(`/${name.toLowerCase()}`);
291
+ state._rawItems = Array.isArray(res) ? res : res.data || [];
292
+ state.loading = false;
293
+ state.success = true;
294
+ state.error = null;
295
+ this._applyTransform(state);
296
+ if (!this.subscribed.has(name)) {
297
+ this.client.subscribe(`db:sync/${name.toLowerCase()}`, (update) => {
298
+ this._handleRemoteUpdate(name, update);
299
+ });
300
+ this.subscribed.add(name);
301
+ }
302
+ } catch (e) {
303
+ state.loading = false;
304
+ state.success = false;
305
+ state.error = e.data?.error || e.message || "Fetch failed";
306
+ this._notify();
307
+ }
308
+ }
309
+ /** @private */
310
+ _applyTransform(state) {
311
+ let result = [...state._rawItems];
312
+ if (state._filter) result = result.filter(state._filter);
313
+ if (state._sort) {
314
+ const { key, direction } = state._sort;
315
+ result.sort((a, b) => {
316
+ if (a[key] === b[key]) return 0;
317
+ return (a[key] > b[key] ? 1 : -1) * (direction === "asc" ? 1 : -1);
318
+ });
319
+ }
320
+ state.items = result;
321
+ this._notify();
322
+ }
323
+ /** @private */
324
+ _handleRemoteUpdate(collection, update) {
325
+ const state = this.data.get(collection);
326
+ if (!state) return;
327
+ const { type, data } = update;
328
+ let items = state._rawItems;
329
+ if (type === "create") {
330
+ items = [...items, data];
331
+ } else if (type === "update") {
332
+ items = items.map((i) => i.id === data.id || i._id === data._id ? { ...i, ...data } : i);
333
+ } else if (type === "delete") {
334
+ items = items.filter((i) => {
335
+ if (data.id != null && i.id === data.id) return false;
336
+ if (data._id != null && i._id === data._id) return false;
337
+ return true;
338
+ });
339
+ }
340
+ state._rawItems = items;
341
+ this._applyTransform(state);
342
+ }
343
+ /** Subscribe for React useSyncExternalStore */
344
+ subscribe(listener) {
345
+ this.listeners.add(listener);
346
+ return () => this.listeners.delete(listener);
347
+ }
348
+ /** @param {string} collection */
349
+ getSnapshot(collection) {
350
+ return this.data.get(collection) || { items: [], loading: false, error: null, success: false };
351
+ }
352
+ /** @private */
353
+ _notify() {
354
+ this.listeners.forEach((l) => l());
355
+ }
356
+ };
357
+
358
+ // src/client/core.ts
359
+ var DolphinClient = class {
360
+ constructor(url = "", deviceId = "", options = {}) {
361
+ if (!url && typeof window !== "undefined") url = window.location.host;
362
+ let protocol = "http:";
363
+ if (url.startsWith("https://")) protocol = "https:";
364
+ else if (url.startsWith("http://")) protocol = "http:";
365
+ else if (typeof window !== "undefined") protocol = window.location.protocol;
366
+ this.host = (url || "localhost").replace(/\/$/, "").replace(/^https?:\/\//, "");
367
+ this.httpUrl = `${protocol}//${this.host}`;
368
+ this.deviceId = deviceId || "web_" + Math.random().toString(36).substr(2, 8);
369
+ this.options = {
370
+ timeout: 15e3,
371
+ chunkSize: 65536,
372
+ // 64 KB
373
+ maxReconnect: 5,
374
+ autoRefreshToken: true,
375
+ ...options
376
+ };
377
+ this.socket = null;
378
+ this.storage = typeof localStorage !== "undefined" ? localStorage : {
379
+ getItem: () => null,
380
+ setItem: () => {
381
+ },
382
+ removeItem: () => {
383
+ }
384
+ };
385
+ this.accessToken = this.storage.getItem("dolphin_token");
386
+ this.api = new APIHandler(this);
387
+ this.auth = new AuthHandler(this);
388
+ this.store = new DolphinStore(this);
389
+ this.handlers = /* @__PURE__ */ new Map();
390
+ this.signalHandlers = /* @__PURE__ */ new Set();
391
+ this.fileHandlers = /* @__PURE__ */ new Set();
392
+ this._offlineQueue = [];
393
+ this.reconnectAttempts = 0;
394
+ }
395
+ /** Save or clear the access token */
396
+ setToken(token) {
397
+ this.accessToken = token;
398
+ token ? this.storage.setItem("dolphin_token", token) : this.storage.removeItem("dolphin_token");
399
+ }
400
+ // ── WebSocket ─────────────────────────────────────────────────────────────
401
+ /** Connect to the Dolphin realtime server */
402
+ async connect() {
403
+ return new Promise((resolve, reject) => {
404
+ const protocol = this.httpUrl.startsWith("https") ? "wss:" : "ws:";
405
+ const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
406
+ console.log(`[Dolphin] Connecting to ${wsUrl}...`);
407
+ this.socket = new WebSocket(wsUrl);
408
+ this.socket.onopen = () => {
409
+ console.log(`[Dolphin] Connected as "${this.deviceId}" \u{1F42C}`);
410
+ this.reconnectAttempts = 0;
411
+ this._flushOfflineQueue();
412
+ resolve();
413
+ };
414
+ this.socket.onmessage = (ev) => this._handleMessage(ev.data);
415
+ this.socket.onclose = () => {
416
+ console.warn("[Dolphin] Connection closed");
417
+ this._maybeReconnect();
418
+ };
419
+ this.socket.onerror = (err) => {
420
+ console.error("[Dolphin] WebSocket error:", err);
421
+ reject(err);
422
+ };
423
+ });
424
+ }
425
+ /** Disconnect cleanly */
426
+ disconnect() {
427
+ if (this.socket) {
428
+ this.socket.onclose = null;
429
+ this.socket.close();
430
+ this.socket = null;
431
+ }
432
+ }
433
+ /** @private */
434
+ _handleMessage(data) {
435
+ try {
436
+ const msg = JSON.parse(data);
437
+ if (msg.type && msg.from && (msg.to === this.deviceId || msg.to === "all")) {
438
+ if (msg.msgId && msg.type !== "ACK") this._sendAck(msg.from, msg.msgId);
439
+ this.signalHandlers.forEach((h) => h(msg));
440
+ }
441
+ if (msg.type === "FILE_AVAILABLE") {
442
+ this.fileHandlers.forEach((h) => h(msg));
443
+ }
444
+ if (msg.type === "FILE_CHUNK") {
445
+ this.saveFileProgress(msg.fileId, msg.chunkIndex);
446
+ this._dispatch("file:chunk", msg);
447
+ this._dispatch(`file:chunk/${msg.fileId}`, msg);
448
+ }
449
+ if (msg.type === "FILE_UPLOAD_ACK") {
450
+ this._dispatch(`file:upload:ack/${msg.fileId}`, msg);
451
+ }
452
+ if (msg.type === "PULL_RESPONSE") {
453
+ this._dispatch("pull:response", msg.payload, msg.topic);
454
+ this._dispatch(`pull:response/${msg.topic}`, msg.payload, msg.topic);
455
+ }
456
+ if (msg.topic && msg.payload !== void 0) {
457
+ this.handlers.forEach((cbs, pattern) => {
458
+ if (this._matchTopic(pattern, msg.topic)) {
459
+ cbs.forEach((cb) => cb(msg.payload, msg.topic));
460
+ }
461
+ });
462
+ }
463
+ } catch {
464
+ this._dispatch("raw", data);
465
+ }
466
+ }
467
+ /** @private */
468
+ _dispatch(pattern, payload, topic) {
469
+ const cbs = this.handlers.get(pattern);
470
+ if (cbs) cbs.forEach((cb) => cb(payload, topic || pattern));
471
+ }
472
+ /** @private */
473
+ _sendRaw(msg) {
474
+ const str = typeof msg === "string" ? msg : JSON.stringify(msg);
475
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
476
+ this.socket.send(str);
477
+ } else {
478
+ if (this._offlineQueue.length < 100) this._offlineQueue.push(str);
479
+ }
480
+ }
481
+ /** Flush buffered messages after reconnect @private */
482
+ _flushOfflineQueue() {
483
+ while (this._offlineQueue.length > 0) {
484
+ const msg = this._offlineQueue.shift();
485
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
486
+ this.socket.send(msg);
487
+ }
488
+ }
489
+ }
490
+ /** @private */
491
+ _sendAck(to, msgId) {
492
+ this._sendRaw({ type: "ACK", from: this.deviceId, to, data: { ackId: msgId }, timestamp: Date.now() });
493
+ }
494
+ /** MQTT wildcard topic matching @private */
495
+ _matchTopic(pattern, topic) {
496
+ if (pattern === topic || pattern === "#") return true;
497
+ const pp = pattern.split("/");
498
+ const tp = topic.split("/");
499
+ if (pp.length !== tp.length && !pattern.includes("#")) return false;
500
+ for (let i = 0; i < pp.length; i++) {
501
+ if (pp[i] === "#") return true;
502
+ if (pp[i] !== "+" && pp[i] !== tp[i]) return false;
503
+ }
504
+ return pp.length === tp.length;
505
+ }
506
+ /** @private */
507
+ _maybeReconnect() {
508
+ if (this.reconnectAttempts < this.options.maxReconnect) {
509
+ this.reconnectAttempts++;
510
+ const delay = Math.pow(2, this.reconnectAttempts) * 1e3;
511
+ console.log(`[Dolphin] Reconnecting in ${delay / 1e3}s (attempt ${this.reconnectAttempts})...`);
512
+ setTimeout(() => this.connect().catch(() => {
513
+ }), delay);
514
+ } else {
515
+ console.error("[Dolphin] Max reconnect attempts reached.");
516
+ }
517
+ }
518
+ // ── Pub/Sub ───────────────────────────────────────────────────────────────
519
+ /**
520
+ * Subscribe to a topic (MQTT wildcards supported: + and #).
521
+ * @param {string} topic
522
+ * @param {TopicCallback} callback
523
+ */
524
+ subscribe(topic, callback) {
525
+ if (!this.handlers.has(topic)) {
526
+ this.handlers.set(topic, /* @__PURE__ */ new Set());
527
+ this._sendRaw({ type: "sub", topic });
528
+ }
529
+ this.handlers.get(topic).add(callback);
530
+ }
531
+ /**
532
+ * Unsubscribe from a topic.
533
+ * @param {string} topic
534
+ * @param {TopicCallback} callback
535
+ */
536
+ unsubscribe(topic, callback) {
537
+ if (this.handlers.has(topic)) {
538
+ const cbs = this.handlers.get(topic);
539
+ cbs.delete(callback);
540
+ if (cbs.size === 0) {
541
+ this.handlers.delete(topic);
542
+ this._sendRaw({ type: "unsub", topic });
543
+ }
544
+ }
545
+ }
546
+ /**
547
+ * Publish a message to a topic. Queued if offline.
548
+ * @param {string} topic
549
+ * @param {any} payload
550
+ */
551
+ publish(topic, payload) {
552
+ this._sendRaw({ topic, payload });
553
+ }
554
+ /**
555
+ * High-frequency data push (IoT sensors).
556
+ * @param {string} topic
557
+ * @param {any} payload
558
+ */
559
+ pubPush(topic, payload) {
560
+ this._sendRaw({ type: "pub", topic, payload });
561
+ }
562
+ /**
563
+ * Request historical data from a topic.
564
+ * @param {string} topic
565
+ * @param {number} [count=10]
566
+ */
567
+ subPull(topic, count = 10) {
568
+ this._sendRaw({ type: "PULL_REQUEST", topic, count });
569
+ }
570
+ // ── File Transfer ─────────────────────────────────────────────────────────
571
+ /**
572
+ * Upload a file to the server in chunks.
573
+ * @param {string} fileId
574
+ * @param {Blob|ArrayBuffer|Uint8Array} fileData
575
+ * @param {string} [filename]
576
+ * @param {function(number): void} [onProgress] — progress callback (0-100)
577
+ * @returns {Promise<void>}
578
+ */
579
+ async pubFile(fileId, fileData, filename = "", onProgress) {
580
+ let buffer;
581
+ if (fileData instanceof Blob) {
582
+ buffer = await fileData.arrayBuffer();
583
+ } else if (fileData instanceof ArrayBuffer) {
584
+ buffer = fileData;
585
+ } else {
586
+ buffer = fileData.buffer || fileData;
587
+ }
588
+ const bytes = new Uint8Array(buffer);
589
+ const chunkSize = this.options.chunkSize;
590
+ const totalChunks = Math.ceil(bytes.length / chunkSize);
591
+ this._sendRaw({
592
+ type: "FILE_UPLOAD_START",
593
+ fileId,
594
+ name: filename,
595
+ size: bytes.length,
596
+ totalChunks,
597
+ chunkSize
598
+ });
599
+ for (let i = 0; i < totalChunks; i++) {
600
+ const chunk = bytes.slice(i * chunkSize, (i + 1) * chunkSize);
601
+ const b64 = this._uint8ToBase64(chunk);
602
+ this._sendRaw({
603
+ type: "FILE_UPLOAD_CHUNK",
604
+ fileId,
605
+ chunkIndex: i,
606
+ totalChunks,
607
+ data: b64
608
+ });
609
+ if (onProgress) onProgress(Math.round((i + 1) / totalChunks * 100));
610
+ if (i % 10 === 0) await new Promise((r) => setTimeout(r, 0));
611
+ }
612
+ this._sendRaw({ type: "FILE_UPLOAD_DONE", fileId });
613
+ }
614
+ /** @private */
615
+ _uint8ToBase64(uint8) {
616
+ let binary = "";
617
+ for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
618
+ if (typeof btoa !== "undefined") return btoa(binary);
619
+ return Buffer.from(binary, "binary").toString("base64");
620
+ }
621
+ /**
622
+ * Download a file from the server by chunks.
623
+ * @param {string} fileId
624
+ * @param {number} [startChunk=0]
625
+ */
626
+ subFile(fileId, startChunk = 0) {
627
+ this._sendRaw({ type: "FILE_REQUEST", fileId, startChunk });
628
+ }
629
+ /**
630
+ * Resume a file download from saved progress.
631
+ * @param {string} fileId
632
+ */
633
+ resumeFile(fileId) {
634
+ const last = parseInt(this.storage.getItem(`dolphin_file_${fileId}`) || "-1");
635
+ this.subFile(fileId, last + 1);
636
+ }
637
+ /**
638
+ * Save download chunk progress.
639
+ * @param {string} fileId
640
+ * @param {number} chunkIndex
641
+ */
642
+ saveFileProgress(fileId, chunkIndex) {
643
+ this.storage.setItem(`dolphin_file_${fileId}`, chunkIndex.toString());
644
+ }
645
+ // ── Signaling ─────────────────────────────────────────────────────────────
646
+ /**
647
+ * @param {function(SignalMessage): void} handler
648
+ */
649
+ onSignal(handler) {
650
+ this.signalHandlers.add(handler);
651
+ }
652
+ /**
653
+ * @param {function(SignalMessage): void} handler
654
+ */
655
+ offSignal(handler) {
656
+ this.signalHandlers.delete(handler);
657
+ }
658
+ /**
659
+ * @param {function(FileMetadata): void} handler
660
+ */
661
+ onFileAvailable(handler) {
662
+ this.fileHandlers.add(handler);
663
+ }
664
+ /**
665
+ * @param {function(FileMetadata): void} handler
666
+ */
667
+ offFileAvailable(handler) {
668
+ this.fileHandlers.delete(handler);
669
+ }
670
+ };
671
+
672
+ // src/client/dom.ts
673
+ function attachDOMBinding(clientProto) {
674
+ clientProto._initDOMBinding = function() {
675
+ if (this._domInitialized) return;
676
+ this._domInitialized = true;
677
+ document.addEventListener("input", (e) => {
678
+ if (!e.target || !e.target.getAttribute) return;
679
+ const topic = e.target.getAttribute("data-rt-push");
680
+ if (topic) {
681
+ const payload = { name: e.target.name, value: e.target.value };
682
+ this.pubPush(topic, payload);
683
+ }
684
+ });
685
+ document.addEventListener("submit", async (e) => {
686
+ if (!e.target || !e.target.getAttribute) return;
687
+ const rtTopic = e.target.getAttribute("data-rt-submit");
688
+ const apiTarget = e.target.getAttribute("data-api-submit");
689
+ if (rtTopic || apiTarget) {
690
+ e.preventDefault();
691
+ const formData = new FormData(e.target);
692
+ const data = Object.fromEntries(formData.entries());
693
+ if (rtTopic) {
694
+ this.publish(rtTopic, data);
695
+ } else if (apiTarget) {
696
+ const parts = apiTarget.trim().split(" ");
697
+ const method = parts.length > 1 ? parts[0].toUpperCase() : "POST";
698
+ const path = parts.length > 1 ? parts[1] : parts[0];
699
+ try {
700
+ const result = await this.api.request(method, path, data);
701
+ const resultBind = e.target.getAttribute("data-api-result");
702
+ if (resultBind) this._updateDOM(resultBind, result);
703
+ const redirect = e.target.getAttribute("data-api-redirect");
704
+ if (redirect) window.location.href = redirect;
705
+ if (e.target.hasAttribute("data-api-reload")) window.location.reload();
706
+ } catch (err) {
707
+ console.error("[Dolphin] API Submit Error:", err);
708
+ }
709
+ }
710
+ }
711
+ });
712
+ document.addEventListener("click", async (e) => {
713
+ if (!e.target || !e.target.closest) return;
714
+ const rtBtn = e.target.closest("[data-rt-click]");
715
+ const apiBtn = e.target.closest("[data-api-click]");
716
+ if (rtBtn) {
717
+ const topic = rtBtn.getAttribute("data-rt-click");
718
+ const actionData = rtBtn.getAttribute("data-rt-payload");
719
+ const payload = actionData ? JSON.parse(actionData) : {};
720
+ this.publish(topic, payload);
721
+ } else if (apiBtn) {
722
+ const apiTarget = apiBtn.getAttribute("data-api-click");
723
+ const actionData = apiBtn.getAttribute("data-api-payload");
724
+ const payload = actionData ? JSON.parse(actionData) : null;
725
+ const parts = apiTarget.trim().split(" ");
726
+ const method = parts.length > 1 ? parts[0].toUpperCase() : "POST";
727
+ const path = parts.length > 1 ? parts[1] : parts[0];
728
+ try {
729
+ const result = await this.api.request(method, path, payload);
730
+ const resultBind = apiBtn.getAttribute("data-api-result");
731
+ if (resultBind) this._updateDOM(resultBind, result);
732
+ const redirect = apiBtn.getAttribute("data-api-redirect");
733
+ if (redirect) window.location.href = redirect;
734
+ if (apiBtn.hasAttribute("data-api-reload")) window.location.reload();
735
+ } catch (err) {
736
+ console.error("[Dolphin] API Click Error:", err);
737
+ }
738
+ }
739
+ });
740
+ this.subscribe("#", (payload, topic) => {
741
+ this._updateDOM(topic, payload);
742
+ });
743
+ this._scanAndFetchAPIBinds();
744
+ };
745
+ clientProto._scanAndFetchAPIBinds = async function() {
746
+ if (typeof document === "undefined") return;
747
+ const elements = document.querySelectorAll("[data-api-get]");
748
+ for (const el of Array.from(elements)) {
749
+ const path = el.getAttribute("data-api-get");
750
+ if (!path) continue;
751
+ try {
752
+ const result = await this.api.get(path);
753
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
754
+ el.value = typeof result === "object" ? result.value !== void 0 ? result.value : "" : result;
755
+ } else {
756
+ el.innerHTML = typeof result === "object" ? result.html || result.text || JSON.stringify(result) : result;
757
+ }
758
+ } catch (e) {
759
+ console.error("[Dolphin] API Get Error:", e);
760
+ }
761
+ }
762
+ };
763
+ clientProto._updateDOM = function(topic, payload) {
764
+ if (typeof document === "undefined") return;
765
+ const elements = document.querySelectorAll(`[data-rt-bind="${topic}"]`);
766
+ elements.forEach((el) => {
767
+ if (el.getAttribute("data-rt-type") === "context" && typeof payload === "object" && payload !== null) {
768
+ const processNode = (node) => {
769
+ if (node.hasAttribute("data-rt-text")) {
770
+ const key = node.getAttribute("data-rt-text");
771
+ if (key && payload[key] !== void 0 && payload[key] !== null) node.textContent = payload[key];
772
+ }
773
+ if (node.hasAttribute("data-rt-html")) {
774
+ const key = node.getAttribute("data-rt-html");
775
+ if (key && payload[key] !== void 0 && payload[key] !== null) node.innerHTML = payload[key];
776
+ }
777
+ if (node.hasAttribute("data-rt-attr")) {
778
+ const attrStr = node.getAttribute("data-rt-attr");
779
+ if (attrStr) {
780
+ attrStr.split(",").forEach((b) => {
781
+ const parts = b.split(":");
782
+ if (parts.length === 2) {
783
+ const attrName = parts[0].trim();
784
+ const key = parts[1].trim();
785
+ if (attrName && key && payload[key] !== void 0 && payload[key] !== null) {
786
+ node.setAttribute(attrName, payload[key]);
787
+ }
788
+ }
789
+ });
790
+ }
791
+ }
792
+ if (node.hasAttribute("data-rt-class")) {
793
+ const classStr = node.getAttribute("data-rt-class");
794
+ if (classStr) {
795
+ classStr.split(",").forEach((b) => {
796
+ const parts = b.split(":");
797
+ if (parts.length === 2) {
798
+ const className = parts[0].trim();
799
+ const key = parts[1].trim();
800
+ if (payload[key]) {
801
+ node.classList.add(className);
802
+ } else {
803
+ node.classList.remove(className);
804
+ }
805
+ }
806
+ });
807
+ }
808
+ }
809
+ if (node.hasAttribute("data-rt-if")) {
810
+ const key = node.getAttribute("data-rt-if");
811
+ if (key) {
812
+ if (payload[key]) {
813
+ node.style.display = "";
814
+ } else {
815
+ node.style.display = "none";
816
+ }
817
+ }
818
+ }
819
+ if (node.hasAttribute("data-rt-hide")) {
820
+ const key = node.getAttribute("data-rt-hide");
821
+ if (key) {
822
+ if (payload[key]) {
823
+ node.style.display = "none";
824
+ } else {
825
+ node.style.display = "";
826
+ }
827
+ }
828
+ }
829
+ };
830
+ processNode(el);
831
+ el.querySelectorAll("[data-rt-text], [data-rt-html], [data-rt-attr], [data-rt-class], [data-rt-if], [data-rt-hide]").forEach(processNode);
832
+ return;
833
+ }
834
+ const template = el.getAttribute("data-rt-template");
835
+ if (template && typeof payload === "object" && payload !== null) {
836
+ if (Array.isArray(payload)) {
837
+ let combinedHTML = "";
838
+ for (const item of payload) {
839
+ let finalItemHTML = template;
840
+ for (let key in item) {
841
+ finalItemHTML = finalItemHTML.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), item[key] !== void 0 && item[key] !== null ? item[key] : "");
842
+ }
843
+ combinedHTML += finalItemHTML;
844
+ }
845
+ el.innerHTML = combinedHTML;
846
+ } else {
847
+ let finalHTML = template;
848
+ for (let key in payload) {
849
+ finalHTML = finalHTML.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), payload[key] !== void 0 && payload[key] !== null ? payload[key] : "");
850
+ }
851
+ el.innerHTML = finalHTML;
852
+ }
853
+ return;
854
+ }
855
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
856
+ el.value = typeof payload === "object" ? payload.value !== void 0 ? payload.value : "" : payload;
857
+ } else {
858
+ el.innerHTML = typeof payload === "object" ? payload.html || payload.text || JSON.stringify(payload) : payload;
859
+ }
860
+ });
861
+ };
862
+ }
863
+
864
+ // src/client/index.ts
865
+ attachDOMBinding(DolphinClient.prototype);
866
+ if (typeof window !== "undefined") {
867
+ window.DolphinClient = DolphinClient;
868
+ window.dolphin = new DolphinClient();
869
+ }
870
+ if (typeof module !== "undefined" && module.exports) {
871
+ module.exports = { DolphinClient };
872
+ }
873
+ return __toCommonJS(index_exports);
874
+ })();