dolphin-client 1.0.0

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/dist/core.js ADDED
@@ -0,0 +1,345 @@
1
+ import { APIHandler } from './api';
2
+ import { AuthHandler } from './auth';
3
+ import { DolphinStore } from './store';
4
+ export class DolphinClient {
5
+ constructor(url = '', deviceId = '', options = {}) {
6
+ if (!url && typeof window !== 'undefined')
7
+ url = window.location.host;
8
+ let protocol = 'http:';
9
+ if (url.startsWith('https://'))
10
+ protocol = 'https:';
11
+ else if (url.startsWith('http://'))
12
+ protocol = 'http:';
13
+ else if (typeof window !== 'undefined')
14
+ protocol = window.location.protocol;
15
+ this.host = (url || 'localhost').replace(/\/$/, '').replace(/^https?:\/\//, '');
16
+ this.httpUrl = `${protocol}//${this.host}`;
17
+ this.deviceId = deviceId || 'web_' + Math.random().toString(36).substr(2, 8);
18
+ /** @type {DolphinClientOptions} */
19
+ this.options = {
20
+ timeout: 15000,
21
+ chunkSize: 65536, // 64 KB
22
+ maxReconnect: 5,
23
+ autoRefreshToken: true,
24
+ ...options,
25
+ };
26
+ /** @type {WebSocket|null} */
27
+ this.socket = null;
28
+ // Storage polyfill
29
+ this.storage = typeof localStorage !== 'undefined' ? localStorage : {
30
+ getItem: () => null,
31
+ setItem: () => { },
32
+ removeItem: () => { },
33
+ };
34
+ /** @type {string|null} */
35
+ this.accessToken = this.storage.getItem('dolphin_token');
36
+ // Sub-handlers
37
+ this.api = new APIHandler(this);
38
+ this.auth = new AuthHandler(this);
39
+ this.store = new DolphinStore(this);
40
+ /** @type {Map<string, Set<TopicCallback>>} */
41
+ this.handlers = new Map();
42
+ /** @type {Set<function(SignalMessage): void>} */
43
+ this.signalHandlers = new Set();
44
+ /** @type {Set<function(FileMetadata): void>} */
45
+ this.fileHandlers = new Set();
46
+ /** @type {Array<string>} — offline message queue */
47
+ this._offlineQueue = [];
48
+ this.reconnectAttempts = 0;
49
+ // Initialize DOM bindings automatically if running in browser
50
+ if (typeof window !== 'undefined' && typeof this._initDOMBinding === 'function') {
51
+ this._initDOMBinding();
52
+ }
53
+ }
54
+ /** Save or clear the access token */
55
+ setToken(token) {
56
+ this.accessToken = token;
57
+ token
58
+ ? this.storage.setItem('dolphin_token', token)
59
+ : this.storage.removeItem('dolphin_token');
60
+ }
61
+ // ── WebSocket ─────────────────────────────────────────────────────────────
62
+ /** Connect to the Dolphin realtime server */
63
+ async connect() {
64
+ return new Promise((resolve, reject) => {
65
+ const protocol = this.httpUrl.startsWith('https') ? 'wss:' : 'ws:';
66
+ const wsUrl = `${protocol}//${this.host}/realtime?deviceId=${this.deviceId}`;
67
+ console.log(`[Dolphin] Connecting to ${wsUrl}...`);
68
+ this.socket = new WebSocket(wsUrl);
69
+ this.socket.onopen = () => {
70
+ console.log(`[Dolphin] Connected as "${this.deviceId}" 🐬`);
71
+ this.reconnectAttempts = 0;
72
+ this._flushOfflineQueue();
73
+ resolve();
74
+ };
75
+ this.socket.onmessage = (ev) => this._handleMessage(ev.data);
76
+ this.socket.onclose = () => {
77
+ console.warn('[Dolphin] Connection closed');
78
+ this._maybeReconnect();
79
+ };
80
+ this.socket.onerror = (err) => {
81
+ console.error('[Dolphin] WebSocket error:', err);
82
+ reject(err);
83
+ };
84
+ });
85
+ }
86
+ /** Disconnect cleanly */
87
+ disconnect() {
88
+ if (this.socket) {
89
+ this.socket.onclose = null; // prevent auto-reconnect
90
+ this.socket.close();
91
+ this.socket = null;
92
+ }
93
+ }
94
+ /** @private */
95
+ _handleMessage(data) {
96
+ try {
97
+ const msg = JSON.parse(data);
98
+ // Signaling
99
+ if (msg.type && msg.from && (msg.to === this.deviceId || msg.to === 'all')) {
100
+ if (msg.msgId && msg.type !== 'ACK')
101
+ this._sendAck(msg.from, msg.msgId);
102
+ this.signalHandlers.forEach(h => h(msg));
103
+ }
104
+ // File events
105
+ if (msg.type === 'FILE_AVAILABLE') {
106
+ this.fileHandlers.forEach(h => h(msg));
107
+ }
108
+ if (msg.type === 'FILE_CHUNK') {
109
+ this.saveFileProgress(msg.fileId, msg.chunkIndex);
110
+ this._dispatch('file:chunk', msg);
111
+ this._dispatch(`file:chunk/${msg.fileId}`, msg);
112
+ }
113
+ if (msg.type === 'FILE_UPLOAD_ACK') {
114
+ this._dispatch(`file:upload:ack/${msg.fileId}`, msg);
115
+ }
116
+ // Pull response
117
+ if (msg.type === 'PULL_RESPONSE') {
118
+ this._dispatch('pull:response', msg.payload, msg.topic);
119
+ this._dispatch(`pull:response/${msg.topic}`, msg.payload, msg.topic);
120
+ }
121
+ // Pub/Sub
122
+ if (msg.topic && msg.payload !== undefined) {
123
+ this.handlers.forEach((cbs, pattern) => {
124
+ if (this._matchTopic(pattern, msg.topic)) {
125
+ cbs.forEach(cb => cb(msg.payload, msg.topic));
126
+ }
127
+ });
128
+ }
129
+ }
130
+ catch {
131
+ this._dispatch('raw', data);
132
+ }
133
+ }
134
+ /** @private */
135
+ _dispatch(pattern, payload, topic) {
136
+ const cbs = this.handlers.get(pattern);
137
+ if (cbs)
138
+ cbs.forEach(cb => cb(payload, topic || pattern));
139
+ }
140
+ /** @private */
141
+ _sendRaw(msg) {
142
+ const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
143
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
144
+ this.socket.send(str);
145
+ }
146
+ else {
147
+ // Buffer for offline queue (max 100 messages)
148
+ if (this._offlineQueue.length < 100)
149
+ this._offlineQueue.push(str);
150
+ }
151
+ }
152
+ /** Flush buffered messages after reconnect @private */
153
+ _flushOfflineQueue() {
154
+ while (this._offlineQueue.length > 0) {
155
+ const msg = this._offlineQueue.shift();
156
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
157
+ this.socket.send(msg);
158
+ }
159
+ }
160
+ }
161
+ /** @private */
162
+ _sendAck(to, msgId) {
163
+ this._sendRaw({ type: 'ACK', from: this.deviceId, to, data: { ackId: msgId }, timestamp: Date.now() });
164
+ }
165
+ /** MQTT wildcard topic matching @private */
166
+ _matchTopic(pattern, topic) {
167
+ if (pattern === topic || pattern === '#')
168
+ return true;
169
+ const pp = pattern.split('/');
170
+ const tp = topic.split('/');
171
+ if (pp.length !== tp.length && !pattern.includes('#'))
172
+ return false;
173
+ for (let i = 0; i < pp.length; i++) {
174
+ if (pp[i] === '#')
175
+ return true;
176
+ if (pp[i] !== '+' && pp[i] !== tp[i])
177
+ return false;
178
+ }
179
+ return pp.length === tp.length;
180
+ }
181
+ /** @private */
182
+ _maybeReconnect() {
183
+ if (this.reconnectAttempts < this.options.maxReconnect) {
184
+ this.reconnectAttempts++;
185
+ const delay = Math.pow(2, this.reconnectAttempts) * 1000;
186
+ console.log(`[Dolphin] Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts})...`);
187
+ setTimeout(() => this.connect().catch(() => { }), delay);
188
+ }
189
+ else {
190
+ console.error('[Dolphin] Max reconnect attempts reached.');
191
+ }
192
+ }
193
+ // ── Pub/Sub ───────────────────────────────────────────────────────────────
194
+ /**
195
+ * Subscribe to a topic (MQTT wildcards supported: + and #).
196
+ * @param {string} topic
197
+ * @param {TopicCallback} callback
198
+ */
199
+ subscribe(topic, callback) {
200
+ if (!this.handlers.has(topic)) {
201
+ this.handlers.set(topic, new Set());
202
+ this._sendRaw({ type: 'sub', topic });
203
+ }
204
+ this.handlers.get(topic).add(callback);
205
+ }
206
+ /**
207
+ * Unsubscribe from a topic.
208
+ * @param {string} topic
209
+ * @param {TopicCallback} callback
210
+ */
211
+ unsubscribe(topic, callback) {
212
+ if (this.handlers.has(topic)) {
213
+ const cbs = this.handlers.get(topic);
214
+ cbs.delete(callback);
215
+ if (cbs.size === 0) {
216
+ this.handlers.delete(topic);
217
+ this._sendRaw({ type: 'unsub', topic });
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Publish a message to a topic. Queued if offline.
223
+ * @param {string} topic
224
+ * @param {any} payload
225
+ */
226
+ publish(topic, payload) {
227
+ this._sendRaw({ topic, payload });
228
+ }
229
+ /**
230
+ * High-frequency data push (IoT sensors).
231
+ * @param {string} topic
232
+ * @param {any} payload
233
+ */
234
+ pubPush(topic, payload) {
235
+ this._sendRaw({ type: 'pub', topic, payload });
236
+ }
237
+ /**
238
+ * Request historical data from a topic.
239
+ * @param {string} topic
240
+ * @param {number} [count=10]
241
+ */
242
+ subPull(topic, count = 10) {
243
+ this._sendRaw({ type: 'PULL_REQUEST', topic, count });
244
+ }
245
+ // ── File Transfer ─────────────────────────────────────────────────────────
246
+ /**
247
+ * Upload a file to the server in chunks.
248
+ * @param {string} fileId
249
+ * @param {Blob|ArrayBuffer|Uint8Array} fileData
250
+ * @param {string} [filename]
251
+ * @param {function(number): void} [onProgress] — progress callback (0-100)
252
+ * @returns {Promise<void>}
253
+ */
254
+ async pubFile(fileId, fileData, filename = '', onProgress) {
255
+ let buffer;
256
+ if (fileData instanceof Blob) {
257
+ buffer = await fileData.arrayBuffer();
258
+ }
259
+ else if (fileData instanceof ArrayBuffer) {
260
+ buffer = fileData;
261
+ }
262
+ else {
263
+ buffer = fileData.buffer || fileData;
264
+ }
265
+ const bytes = new Uint8Array(buffer);
266
+ const chunkSize = this.options.chunkSize;
267
+ const totalChunks = Math.ceil(bytes.length / chunkSize);
268
+ // Send file metadata first
269
+ this._sendRaw({
270
+ type: 'FILE_UPLOAD_START',
271
+ fileId,
272
+ name: filename,
273
+ size: bytes.length,
274
+ totalChunks,
275
+ chunkSize,
276
+ });
277
+ for (let i = 0; i < totalChunks; i++) {
278
+ const chunk = bytes.slice(i * chunkSize, (i + 1) * chunkSize);
279
+ const b64 = this._uint8ToBase64(chunk);
280
+ this._sendRaw({
281
+ type: 'FILE_UPLOAD_CHUNK',
282
+ fileId,
283
+ chunkIndex: i,
284
+ totalChunks,
285
+ data: b64,
286
+ });
287
+ if (onProgress)
288
+ onProgress(Math.round(((i + 1) / totalChunks) * 100));
289
+ // Small yield to prevent blocking
290
+ if (i % 10 === 0)
291
+ await new Promise(r => setTimeout(r, 0));
292
+ }
293
+ this._sendRaw({ type: 'FILE_UPLOAD_DONE', fileId });
294
+ }
295
+ /** @private */
296
+ _uint8ToBase64(uint8) {
297
+ let binary = '';
298
+ for (let i = 0; i < uint8.length; i++)
299
+ binary += String.fromCharCode(uint8[i]);
300
+ if (typeof btoa !== 'undefined')
301
+ return btoa(binary);
302
+ return Buffer.from(binary, 'binary').toString('base64');
303
+ }
304
+ /**
305
+ * Download a file from the server by chunks.
306
+ * @param {string} fileId
307
+ * @param {number} [startChunk=0]
308
+ */
309
+ subFile(fileId, startChunk = 0) {
310
+ this._sendRaw({ type: 'FILE_REQUEST', fileId, startChunk });
311
+ }
312
+ /**
313
+ * Resume a file download from saved progress.
314
+ * @param {string} fileId
315
+ */
316
+ resumeFile(fileId) {
317
+ const last = parseInt(this.storage.getItem(`dolphin_file_${fileId}`) || '-1');
318
+ this.subFile(fileId, last + 1);
319
+ }
320
+ /**
321
+ * Save download chunk progress.
322
+ * @param {string} fileId
323
+ * @param {number} chunkIndex
324
+ */
325
+ saveFileProgress(fileId, chunkIndex) {
326
+ this.storage.setItem(`dolphin_file_${fileId}`, chunkIndex.toString());
327
+ }
328
+ // ── Signaling ─────────────────────────────────────────────────────────────
329
+ /**
330
+ * @param {function(SignalMessage): void} handler
331
+ */
332
+ onSignal(handler) { this.signalHandlers.add(handler); }
333
+ /**
334
+ * @param {function(SignalMessage): void} handler
335
+ */
336
+ offSignal(handler) { this.signalHandlers.delete(handler); }
337
+ /**
338
+ * @param {function(FileMetadata): void} handler
339
+ */
340
+ onFileAvailable(handler) { this.fileHandlers.add(handler); }
341
+ /**
342
+ * @param {function(FileMetadata): void} handler
343
+ */
344
+ offFileAvailable(handler) { this.fileHandlers.delete(handler); }
345
+ }