dolphin-server-modules 2.11.0 → 2.11.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/TUTORIAL_NEPALI.md +181 -0
- package/dist/adapters/mongoose/index.d.ts +4 -4
- package/dist/adapters/mongoose/index.js.map +1 -1
- package/dist/adapters/mongoose/index.test.d.ts +1 -0
- package/dist/adapters/mongoose/index.test.js +145 -0
- package/dist/adapters/mongoose/index.test.js.map +1 -0
- package/dist/adapters/mongoose/integration.test.d.ts +5 -0
- package/dist/adapters/mongoose/integration.test.js +217 -0
- package/dist/adapters/mongoose/integration.test.js.map +1 -0
- package/dist/ai/dolphin-agent/agent.d.ts +5 -0
- package/dist/ai/dolphin-agent/agent.js +93 -35
- package/dist/ai/dolphin-agent/agent.js.map +1 -1
- package/dist/ai/dolphin-agent/config.js +30 -37
- package/dist/ai/dolphin-agent/config.js.map +1 -1
- package/dist/auth/auth.test.d.ts +1 -0
- package/dist/auth/auth.test.js +286 -0
- package/dist/auth/auth.test.js.map +1 -0
- package/dist/authController/authController.test.d.ts +1 -0
- package/dist/authController/authController.test.js +359 -0
- package/dist/authController/authController.test.js.map +1 -0
- package/dist/bin/cli.js +494 -165
- package/dist/bin/cli.js.map +1 -1
- package/dist/client.test.d.ts +22 -0
- package/dist/client.test.js +573 -0
- package/dist/client.test.js.map +1 -0
- package/dist/controller/controller.test.d.ts +1 -0
- package/dist/controller/controller.test.js +37 -0
- package/dist/controller/controller.test.js.map +1 -0
- package/dist/curd/crud.test.d.ts +1 -0
- package/dist/curd/crud.test.js +104 -0
- package/dist/curd/crud.test.js.map +1 -0
- package/dist/demo-server.d.ts +1 -0
- package/dist/demo-server.js +191 -0
- package/dist/demo-server.js.map +1 -0
- package/dist/djson/djson.test.d.ts +1 -0
- package/dist/djson/djson.test.js +200 -0
- package/dist/djson/djson.test.js.map +1 -0
- package/dist/dolphin-bench.d.ts +1 -0
- package/dist/dolphin-bench.js +63 -0
- package/dist/dolphin-bench.js.map +1 -0
- package/dist/hard-performance-test.d.ts +1 -0
- package/dist/hard-performance-test.js +97 -0
- package/dist/hard-performance-test.js.map +1 -0
- package/dist/middleware/zod.test.d.ts +1 -0
- package/dist/middleware/zod.test.js +74 -0
- package/dist/middleware/zod.test.js.map +1 -0
- package/dist/performance-test.d.ts +1 -0
- package/dist/performance-test.js +92 -0
- package/dist/performance-test.js.map +1 -0
- package/dist/real-test-mongoose.d.ts +1 -0
- package/dist/real-test-mongoose.js +104 -0
- package/dist/real-test-mongoose.js.map +1 -0
- package/dist/realtime/camera.d.ts +119 -0
- package/dist/realtime/camera.js +299 -0
- package/dist/realtime/camera.js.map +1 -0
- package/dist/realtime/camera.test.d.ts +1 -0
- package/dist/realtime/camera.test.js +345 -0
- package/dist/realtime/camera.test.js.map +1 -0
- package/dist/realtime/index.d.ts +2 -0
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/realtime.test.d.ts +1 -0
- package/dist/realtime/realtime.test.js +623 -0
- package/dist/realtime/realtime.test.js.map +1 -0
- package/dist/realtime/rtsp.d.ts +65 -0
- package/dist/realtime/rtsp.js +410 -0
- package/dist/realtime/rtsp.js.map +1 -0
- package/dist/realtime/rtsp.test.d.ts +1 -0
- package/dist/realtime/rtsp.test.js +361 -0
- package/dist/realtime/rtsp.test.js.map +1 -0
- package/dist/router/router.test.d.ts +1 -0
- package/dist/router/router.test.js +45 -0
- package/dist/router/router.test.js.map +1 -0
- package/dist/server/server.test.d.ts +1 -0
- package/dist/server/server.test.js +299 -0
- package/dist/server/server.test.js.map +1 -0
- package/dist/services/ai-service.js +22 -11
- package/dist/services/ai-service.js.map +1 -1
- package/dist/signaling/signaling.test.d.ts +1 -0
- package/dist/signaling/signaling.test.js +112 -0
- package/dist/signaling/signaling.test.js.map +1 -0
- package/dist/swagger/swagger.js +31 -31
- package/dist/swagger/swagger.test.d.ts +1 -0
- package/dist/swagger/swagger.test.js +38 -0
- package/dist/swagger/swagger.test.js.map +1 -0
- package/dist/templates/index.d.ts +6 -0
- package/dist/templates/index.js +282 -105
- package/dist/templates/index.js.map +1 -1
- package/dist/test-2fa-real.d.ts +1 -0
- package/dist/test-2fa-real.js +105 -0
- package/dist/test-2fa-real.js.map +1 -0
- package/dist/test-dolphin.d.ts +1 -0
- package/dist/test-dolphin.js +98 -0
- package/dist/test-dolphin.js.map +1 -0
- package/dist/utils/ctx.d.ts +50 -0
- package/dist/utils/ctx.js +82 -0
- package/dist/utils/ctx.js.map +1 -0
- package/package.json +155 -45
- package/scripts/client.js +838 -0
- package/scripts/dolphin-persist.js +211 -0
|
@@ -0,0 +1,838 @@
|
|
|
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 };
|