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/LICENSE +15 -0
- package/README.md +100 -0
- package/dist/a11y.d.ts +1 -0
- package/dist/animation.d.ts +1 -0
- package/dist/api.d.ts +30 -0
- package/dist/api.js +112 -0
- package/dist/auth.d.ts +56 -0
- package/dist/auth.js +120 -0
- package/dist/collab.d.ts +1 -0
- package/dist/core.d.ts +119 -0
- package/dist/core.js +345 -0
- package/dist/dolphin-client.js +2042 -0
- package/dist/dom.d.ts +1 -0
- package/dist/dom.js +316 -0
- package/dist/dragdrop.d.ts +1 -0
- package/dist/i18n.d.ts +1 -0
- package/dist/index.cjs +2040 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +2018 -0
- package/dist/offline.d.ts +24 -0
- package/dist/pwa.d.ts +1 -0
- package/dist/store.d.ts +22 -0
- package/dist/store.js +131 -0
- package/dist/testing.d.ts +20 -0
- package/dist/validation.d.ts +2 -0
- package/package.json +53 -0
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
|
+
}
|