bytex-sdk 5.2.0 → 5.4.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.
Files changed (2) hide show
  1. package/index.js +593 -0
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -121,6 +121,186 @@ export class BytexCloud {
121
121
  }
122
122
 
123
123
  _requireKey() { if (!this.apiKey) throw new Error('API Key required!'); }
124
+
125
+ /**
126
+ * Copy a file within the same project under a new name.
127
+ */
128
+ async copy(sourceName, destName) {
129
+ return this._execute(async () => {
130
+ this._requireKey();
131
+ const src = sourceName.endsWith('.btx') ? sourceName : `${sourceName}.stream.btx`;
132
+ const { data: { session } } = await this.supabase.auth.getSession();
133
+ const res = await fetch(`${this.workerUrl}?action=view&key=${this.apiKey}&file=${encodeURIComponent(src)}&_cb=${Date.now()}`, {
134
+ headers: { 'Authorization': `Bearer ${session?.access_token || ''}` }
135
+ });
136
+ if (!res.ok) throw new Error(`File not found: ${sourceName}`);
137
+ const data = await res.arrayBuffer();
138
+ const fd = new FormData();
139
+ fd.append('file', new Blob([data]), destName);
140
+ const upRes = await fetch(`${this.workerUrl}?action=upload&key=${this.apiKey}`, { method: 'POST', body: fd });
141
+ if (!upRes.ok) throw new Error(await upRes.text());
142
+ return { source: sourceName, dest: destName };
143
+ }, 'Copy');
144
+ }
145
+
146
+ /**
147
+ * Move a file to a different project (API key).
148
+ * @param {string} name - File name to move
149
+ * @param {string} destApiKey - Destination project's API key
150
+ */
151
+ async move(name, destApiKey) {
152
+ return this._execute(async () => {
153
+ this._requireKey();
154
+ const src = name.endsWith('.btx') ? name : `${name}.stream.btx`;
155
+ const { data: { session } } = await this.supabase.auth.getSession();
156
+ // Download from source
157
+ const res = await fetch(`${this.workerUrl}?action=view&key=${this.apiKey}&file=${encodeURIComponent(src)}&_cb=${Date.now()}`, {
158
+ headers: { 'Authorization': `Bearer ${session?.access_token || ''}` }
159
+ });
160
+ if (!res.ok) throw new Error(`File not found: ${name}`);
161
+ const data = await res.arrayBuffer();
162
+ // Upload to destination
163
+ const fd = new FormData();
164
+ fd.append('file', new Blob([data]), name);
165
+ await fetch(`${this.workerUrl}?action=upload&key=${destApiKey}`, { method: 'POST', body: fd });
166
+ // Delete from source
167
+ await fetch(`${this.workerUrl}?action=delete&key=${this.apiKey}&file=${encodeURIComponent(src)}`, {
168
+ method: 'DELETE', headers: { 'Authorization': `Bearer ${session?.access_token || ''}` }
169
+ });
170
+ return { moved: name, to: destApiKey };
171
+ }, 'Move');
172
+ }
173
+
174
+ /**
175
+ * Tag a file with one or more labels.
176
+ * @param {string} fileName - The file to tag
177
+ * @param {string[]} tags - Array of tag strings
178
+ */
179
+ async tag(fileName, tags) {
180
+ return this._execute(async () => {
181
+ this._requireKey();
182
+ const existing = await this._getMeta('tags', fileName) || [];
183
+ const merged = [...new Set([...existing, ...tags])];
184
+ await this._setMeta('tags', fileName, merged);
185
+ return { fileName, tags: merged };
186
+ }, 'Tag');
187
+ }
188
+
189
+ async getTags(fileName) {
190
+ return this._execute(async () => {
191
+ this._requireKey();
192
+ return await this._getMeta('tags', fileName) || [];
193
+ }, 'GetTags');
194
+ }
195
+
196
+ async removeTag(fileName, tag) {
197
+ return this._execute(async () => {
198
+ this._requireKey();
199
+ const existing = await this._getMeta('tags', fileName) || [];
200
+ const updated = existing.filter(t => t !== tag);
201
+ await this._setMeta('tags', fileName, updated);
202
+ return { fileName, tags: updated };
203
+ }, 'RemoveTag');
204
+ }
205
+
206
+ /**
207
+ * Pin a file to protect it from bulk operations.
208
+ */
209
+ async pin(fileName) {
210
+ return this._execute(async () => {
211
+ this._requireKey();
212
+ const existing = await this._getMeta('pins', '__global') || [];
213
+ if (!existing.includes(fileName)) {
214
+ await this._setMeta('pins', '__global', [...existing, fileName]);
215
+ }
216
+ return { pinned: fileName };
217
+ }, 'Pin');
218
+ }
219
+
220
+ async unpin(fileName) {
221
+ return this._execute(async () => {
222
+ this._requireKey();
223
+ const existing = await this._getMeta('pins', '__global') || [];
224
+ await this._setMeta('pins', '__global', existing.filter(f => f !== fileName));
225
+ return { unpinned: fileName };
226
+ }, 'Unpin');
227
+ }
228
+
229
+ async getPins() {
230
+ return this._execute(async () => {
231
+ this._requireKey();
232
+ return await this._getMeta('pins', '__global') || [];
233
+ }, 'GetPins');
234
+ }
235
+
236
+ /**
237
+ * Save a versioned snapshot of a file.
238
+ */
239
+ async saveVersion(name) {
240
+ return this._execute(async () => {
241
+ this._requireKey();
242
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
243
+ return await this.copy(name, `__version__${ts}__${name}`);
244
+ }, 'SaveVersion');
245
+ }
246
+
247
+ /**
248
+ * List all saved versions of a file.
249
+ */
250
+ async listVersions(name) {
251
+ return this._execute(async () => {
252
+ this._requireKey();
253
+ const { data: { session } } = await this.supabase.auth.getSession();
254
+ const res = await fetch(`${this.workerUrl}?action=list&key=${this.apiKey}`, {
255
+ headers: { 'Authorization': `Bearer ${session?.access_token || ''}` }
256
+ });
257
+ const text = await res.text();
258
+ const files = text.trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
259
+ return files.filter(f => (f.file_name || '').startsWith('__version__') && (f.file_name || '').endsWith(name));
260
+ }, 'ListVersions');
261
+ }
262
+
263
+ /**
264
+ * Register a webhook URL to receive event notifications.
265
+ * Note: webhooks are stored locally/in Supabase. Your server must listen at the endpoint.
266
+ */
267
+ async addWebhook(url, events = ['upload', 'delete']) {
268
+ return this._execute(async () => {
269
+ this._requireKey();
270
+ const existing = await this._getMeta('webhooks', '__global') || [];
271
+ const updated = [...existing.filter(w => w.url !== url), { url, events, createdAt: new Date().toISOString() }];
272
+ await this._setMeta('webhooks', '__global', updated);
273
+ return { url, events };
274
+ }, 'AddWebhook');
275
+ }
276
+
277
+ async getWebhooks() {
278
+ return this._execute(async () => {
279
+ this._requireKey();
280
+ return await this._getMeta('webhooks', '__global') || [];
281
+ }, 'GetWebhooks');
282
+ }
283
+
284
+ async removeWebhook(url) {
285
+ return this._execute(async () => {
286
+ this._requireKey();
287
+ const existing = await this._getMeta('webhooks', '__global') || [];
288
+ await this._setMeta('webhooks', '__global', existing.filter(w => w.url !== url));
289
+ return { removed: url };
290
+ }, 'RemoveWebhook');
291
+ }
292
+
293
+ // Internal metadata helper using Supabase
294
+ async _getMeta(type, key) {
295
+ try {
296
+ const { data } = await this.supabase.from('bytex_metadata').select('value').eq('api_key', this.apiKey).eq('meta_type', type).eq('meta_key', key).single();
297
+ return data?.value || null;
298
+ } catch { return null; }
299
+ }
300
+
301
+ async _setMeta(type, key, value) {
302
+ await this.supabase.from('bytex_metadata').upsert({ api_key: this.apiKey, meta_type: type, meta_key: key, value }, { onConflict: 'api_key,meta_type,meta_key' });
303
+ }
124
304
  }
125
305
 
126
306
  /**
@@ -225,3 +405,416 @@ export class BytexAI {
225
405
  return data.candidates[0].content.parts[0].text;
226
406
  }
227
407
  }
408
+
409
+ // ════════════════════════════════════════════════════════════════
410
+ // ByteX REALTIME — Database Realtime via Supabase
411
+ // Strategy: Uses ByteX's Supabase OR user's own (BYOC)
412
+ // Free tier: 200 concurrent connections per Supabase project
413
+ // ════════════════════════════════════════════════════════════════
414
+ /**
415
+ * BytexRealtime — Subscribe to database changes in real-time.
416
+ *
417
+ * Usage:
418
+ * const rt = new BytexRealtime(bytex.supabase);
419
+ * rt.on('api_keys', (payload) => console.log(payload));
420
+ */
421
+ export class BytexRealtime {
422
+ constructor(supabaseClient) {
423
+ if (!supabaseClient) throw new Error('[BytexRealtime] Pass a Supabase client. Use: new BytexRealtime(bytex.supabase)');
424
+ this.client = supabaseClient;
425
+ this._channels = new Map();
426
+ }
427
+
428
+ /**
429
+ * Subscribe to realtime changes on a table.
430
+ * @param {string} table - Table name (e.g. 'api_keys', 'files')
431
+ * @param {function} callback - Receives { event, new, old }
432
+ * @param {{ event?: 'INSERT'|'UPDATE'|'DELETE'|'*', filter?: string }} options
433
+ */
434
+ on(table, callback, options = {}) {
435
+ const event = options.event || '*';
436
+ const channelName = `bytex-rt-${table}-${Date.now()}`;
437
+ const channel = this.client
438
+ .channel(channelName)
439
+ .on('postgres_changes', {
440
+ event,
441
+ schema: 'public',
442
+ table,
443
+ ...(options.filter && { filter: options.filter })
444
+ }, (payload) => callback(payload))
445
+ .subscribe();
446
+ this._channels.set(channelName, channel);
447
+ return channelName;
448
+ }
449
+
450
+ /**
451
+ * Subscribe to a broadcast channel (WebSocket replacement).
452
+ * @param {string} channelName - Unique room/channel name
453
+ * @param {function} callback - Receives { event, payload }
454
+ */
455
+ join(channelName, callback) {
456
+ const channel = this.client.channel(channelName);
457
+ channel.on('broadcast', { event: '*' }, (msg) => callback(msg)).subscribe();
458
+ this._channels.set(channelName, channel);
459
+ return {
460
+ send: (event, payload) => channel.send({ type: 'broadcast', event, payload }),
461
+ leave: () => this.off(channelName)
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Unsubscribe from a channel.
467
+ * @param {string} channelName
468
+ */
469
+ off(channelName) {
470
+ const ch = this._channels.get(channelName);
471
+ if (ch) { this.client.removeChannel(ch); this._channels.delete(channelName); }
472
+ }
473
+
474
+ /** Remove all active subscriptions. */
475
+ destroy() {
476
+ this._channels.forEach((ch) => this.client.removeChannel(ch));
477
+ this._channels.clear();
478
+ }
479
+ }
480
+
481
+ // ════════════════════════════════════════════════════════════════
482
+ // ByteX CRON — Scheduled Jobs via Cloudflare Workers Cron Triggers
483
+ // Strategy: BYOC — user provides their Cloudflare API token
484
+ // Free tier: 3 Cron Triggers per Cloudflare account (FREE)
485
+ // ════════════════════════════════════════════════════════════════
486
+ /**
487
+ * BytexCron — Create and manage Cloudflare Workers Cron Triggers.
488
+ *
489
+ * Requires BYOC (user's Cloudflare account):
490
+ * const cron = new BytexCron({ cfToken: '...', cfAccountId: '...', workerName: 'my-worker' });
491
+ */
492
+ export class BytexCron {
493
+ constructor({ cfToken, cfAccountId, workerName } = {}) {
494
+ if (!cfToken || !cfAccountId || !workerName)
495
+ throw new Error('[BytexCron] Required: { cfToken, cfAccountId, workerName } — Use BYOC config from bytex dashboard.');
496
+ this.token = cfToken;
497
+ this.accountId = cfAccountId;
498
+ this.workerName = workerName;
499
+ this.base = `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}`;
500
+ }
501
+
502
+ /** List all existing cron triggers for the worker. */
503
+ async list() {
504
+ const res = await fetch(`${this.base}/workers/scripts/${this.workerName}/schedules`, {
505
+ headers: { 'Authorization': `Bearer ${this.token}` }
506
+ });
507
+ const data = await res.json();
508
+ if (!data.success) throw new Error(`[BytexCron] ${JSON.stringify(data.errors)}`);
509
+ return data.result?.schedules || [];
510
+ }
511
+
512
+ /**
513
+ * Add a new cron trigger (max 3 in free tier).
514
+ * @param {string} cron - Cron expression (e.g. '0 0 * * *' = daily at midnight)
515
+ */
516
+ async add(cron) {
517
+ const existing = await this.list();
518
+ const schedules = [...existing.map(s => ({ cron: s.cron })), { cron }];
519
+ const res = await fetch(`${this.base}/workers/scripts/${this.workerName}/schedules`, {
520
+ method: 'PUT',
521
+ headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
522
+ body: JSON.stringify(schedules)
523
+ });
524
+ const data = await res.json();
525
+ if (!data.success) throw new Error(`[BytexCron] ${JSON.stringify(data.errors)}`);
526
+ return data.result;
527
+ }
528
+
529
+ /**
530
+ * Remove a cron trigger.
531
+ * @param {string} cron - The exact cron string to remove
532
+ */
533
+ async remove(cron) {
534
+ const existing = await this.list();
535
+ const schedules = existing.filter(s => s.cron !== cron).map(s => ({ cron: s.cron }));
536
+ const res = await fetch(`${this.base}/workers/scripts/${this.workerName}/schedules`, {
537
+ method: 'PUT',
538
+ headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
539
+ body: JSON.stringify(schedules)
540
+ });
541
+ const data = await res.json();
542
+ if (!data.success) throw new Error(`[BytexCron] ${JSON.stringify(data.errors)}`);
543
+ return data.result;
544
+ }
545
+ }
546
+
547
+ // ════════════════════════════════════════════════════════════════
548
+ // ByteX QUEUE — Message Queue via Cloudflare Queues
549
+ // Strategy: BYOC — user provides their Cloudflare API token
550
+ // Free tier: 1,000,000 messages/month per account (FREE)
551
+ // ════════════════════════════════════════════════════════════════
552
+ /**
553
+ * BytexQueue — Create and publish to Cloudflare Queues.
554
+ *
555
+ * Requires BYOC (user's Cloudflare account):
556
+ * const queue = new BytexQueue({ cfToken: '...', cfAccountId: '...' });
557
+ */
558
+ export class BytexQueue {
559
+ constructor({ cfToken, cfAccountId } = {}) {
560
+ if (!cfToken || !cfAccountId)
561
+ throw new Error('[BytexQueue] Required: { cfToken, cfAccountId } — Use BYOC config from bytex dashboard.');
562
+ this.token = cfToken;
563
+ this.accountId = cfAccountId;
564
+ this.base = `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/queues`;
565
+ }
566
+
567
+ /** List all existing queues. */
568
+ async list() {
569
+ const res = await fetch(this.base, { headers: { 'Authorization': `Bearer ${this.token}` } });
570
+ const data = await res.json();
571
+ if (!data.success) throw new Error(`[BytexQueue] ${JSON.stringify(data.errors)}`);
572
+ return data.result || [];
573
+ }
574
+
575
+ /**
576
+ * Create a new queue.
577
+ * @param {string} name - Queue name (lowercase, hyphens only)
578
+ */
579
+ async create(name) {
580
+ const res = await fetch(this.base, {
581
+ method: 'POST',
582
+ headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
583
+ body: JSON.stringify({ queue_name: name })
584
+ });
585
+ const data = await res.json();
586
+ if (!data.success) throw new Error(`[BytexQueue] ${JSON.stringify(data.errors)}`);
587
+ return data.result;
588
+ }
589
+
590
+ /**
591
+ * Delete a queue.
592
+ * @param {string} name - Queue name
593
+ */
594
+ async delete(name) {
595
+ const res = await fetch(`${this.base}/${name}`, {
596
+ method: 'DELETE',
597
+ headers: { 'Authorization': `Bearer ${this.token}` }
598
+ });
599
+ const data = await res.json();
600
+ if (!data.success) throw new Error(`[BytexQueue] ${JSON.stringify(data.errors)}`);
601
+ return true;
602
+ }
603
+
604
+ /**
605
+ * Publish a message to a queue.
606
+ * @param {string} queueName - Target queue
607
+ * @param {object|string} body - Message content
608
+ * @param {object} options - { delaySeconds? }
609
+ */
610
+ async publish(queueName, body, options = {}) {
611
+ const message = { body: typeof body === 'string' ? body : JSON.stringify(body) };
612
+ if (options.delaySeconds) message.delay_seconds = options.delaySeconds;
613
+ const res = await fetch(`${this.base}/${queueName}/messages`, {
614
+ method: 'POST',
615
+ headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
616
+ body: JSON.stringify({ messages: [message] })
617
+ });
618
+ const data = await res.json();
619
+ if (!data.success) throw new Error(`[BytexQueue] ${JSON.stringify(data.errors)}`);
620
+ return data.result;
621
+ }
622
+
623
+ /**
624
+ * Publish multiple messages in a single batch (max 100).
625
+ * @param {string} queueName - Target queue
626
+ * @param {Array<object|string>} items - Array of message bodies
627
+ */
628
+ async publishBatch(queueName, items) {
629
+ const messages = items.map(body => ({ body: typeof body === 'string' ? body : JSON.stringify(body) }));
630
+ const res = await fetch(`${this.base}/${queueName}/messages`, {
631
+ method: 'POST',
632
+ headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
633
+ body: JSON.stringify({ messages })
634
+ });
635
+ const data = await res.json();
636
+ if (!data.success) throw new Error(`[BytexQueue] ${JSON.stringify(data.errors)}`);
637
+ return data.result;
638
+ }
639
+ }
640
+
641
+ // ════════════════════════════════════════════════════════════════
642
+ // ByteX PROXY — Reverse Proxy via Cloudflare Workers
643
+ // Strategy: Uses ByteX's Cloudflare (no BYOC needed for basic use)
644
+ // Free tier: 100,000 requests/day (FREE)
645
+ // ════════════════════════════════════════════════════════════════
646
+ /**
647
+ * BytexProxy — Deploy and manage Cloudflare Worker reverse proxies.
648
+ *
649
+ * Usage:
650
+ * // Use ByteX's proxy endpoint (no config needed):
651
+ * const url = BytexProxy.buildUrl('https://api.target.com/endpoint', { headers: {...} });
652
+ *
653
+ * // Or deploy your own proxy Worker (BYOC):
654
+ * const proxy = new BytexProxy({ cfToken: '...', cfAccountId: '...' });
655
+ * await proxy.deploy('my-proxy', 'https://api.target.com');
656
+ */
657
+ export class BytexProxy {
658
+ constructor({ cfToken, cfAccountId } = {}) {
659
+ this.token = cfToken;
660
+ this.accountId = cfAccountId;
661
+ }
662
+
663
+ /**
664
+ * Build a proxied URL via ByteX's own proxy layer.
665
+ * Useful for CORS bypass and header injection.
666
+ * @param {string} targetUrl - The destination URL to proxy
667
+ * @param {{ headers?: object }} options
668
+ */
669
+ static buildUrl(targetUrl, options = {}) {
670
+ const encoded = encodeURIComponent(targetUrl);
671
+ const base = 'https://api.bytex.work/?action=proxy&target=';
672
+ return `${base}${encoded}`;
673
+ }
674
+
675
+ /**
676
+ * Deploy a Cloudflare Worker that proxies to a target origin (BYOC).
677
+ * @param {string} workerName - Unique name for this proxy worker
678
+ * @param {string} targetOrigin - Base URL of the backend (e.g. 'https://my-api.com')
679
+ * @param {{ injectHeaders?: object, stripHeaders?: string[] }} options
680
+ */
681
+ async deploy(workerName, targetOrigin, options = {}) {
682
+ if (!this.token || !this.accountId)
683
+ throw new Error('[BytexProxy] BYOC required for deploy. Pass { cfToken, cfAccountId }.');
684
+
685
+ const injectHeaders = options.injectHeaders
686
+ ? Object.entries(options.injectHeaders).map(([k, v]) => `req.headers.set('${k}', '${v}');`).join('\n ')
687
+ : '';
688
+ const stripHeaders = (options.stripHeaders || [])
689
+ .map(h => `req.headers.delete('${h}');`).join('\n ');
690
+
691
+ const workerScript = `
692
+ export default {
693
+ async fetch(request) {
694
+ const url = new URL(request.url);
695
+ const target = new URL('${targetOrigin}' + url.pathname + url.search);
696
+ const req = new Request(target, request);
697
+ ${injectHeaders}
698
+ ${stripHeaders}
699
+ const response = await fetch(req);
700
+ const res = new Response(response.body, response);
701
+ res.headers.set('Access-Control-Allow-Origin', '*');
702
+ return res;
703
+ }
704
+ }`;
705
+
706
+ const fd = new FormData();
707
+ fd.append('metadata', JSON.stringify({ main_module: 'worker.js', compatibility_date: '2024-01-01' }));
708
+ fd.append('worker.js', new Blob([workerScript], { type: 'application/javascript+module' }), 'worker.js');
709
+
710
+ const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${this.accountId}/workers/scripts/${workerName}`, {
711
+ method: 'PUT',
712
+ headers: { 'Authorization': `Bearer ${this.token}` },
713
+ body: fd
714
+ });
715
+ const data = await res.json();
716
+ if (!data.success) throw new Error(`[BytexProxy] ${JSON.stringify(data.errors)}`);
717
+ return { workerName, url: `https://${workerName}.workers.dev`, target: targetOrigin };
718
+ }
719
+ }
720
+
721
+ // ════════════════════════════════════════════════════════════════
722
+ // ByteX MQTT — IoT Messaging via user's MQTT Broker
723
+ // Strategy: BYOC — user provides their broker (HiveMQ/EMQX/any)
724
+ // Free tier: HiveMQ Cloud free (10 devices), EMQX Serverless (1M msg/mo)
725
+ // ════════════════════════════════════════════════════════════════
726
+ /**
727
+ * BytexMQTT — Lightweight MQTT client wrapper (BYOC).
728
+ *
729
+ * Requires the 'mqtt' package: npm install mqtt
730
+ * Requires user's own MQTT broker (HiveMQ, EMQX, Mosquitto, etc.)
731
+ *
732
+ * Usage:
733
+ * const mq = new BytexMQTT({
734
+ * brokerUrl: 'mqtts://your-cluster.hivemq.cloud:8883',
735
+ * username: 'user', password: 'pass'
736
+ * });
737
+ * await mq.connect();
738
+ * mq.subscribe('bytex/files/#', (topic, msg) => console.log(topic, msg));
739
+ * mq.publish('bytex/files/new', { name: 'photo.jpg' });
740
+ */
741
+ export class BytexMQTT {
742
+ constructor({ brokerUrl, username, password, clientId } = {}) {
743
+ if (!brokerUrl)
744
+ throw new Error('[BytexMQTT] Required: { brokerUrl } — Get free broker at hivemq.com or emqx.com (BYOC).');
745
+ this.brokerUrl = brokerUrl;
746
+ this.options = {
747
+ username, password,
748
+ clientId: clientId || `bytex-${Math.random().toString(36).substring(2, 9)}`,
749
+ clean: true,
750
+ reconnectPeriod: 3000,
751
+ connectTimeout: 10000,
752
+ };
753
+ this.client = null;
754
+ this._handlers = new Map();
755
+ }
756
+
757
+ /**
758
+ * Connect to the MQTT broker.
759
+ * @returns {Promise<void>}
760
+ */
761
+ async connect() {
762
+ let mqtt;
763
+ try { mqtt = (await import('mqtt')).default; }
764
+ catch { throw new Error('[BytexMQTT] Install the mqtt package first: npm install mqtt'); }
765
+
766
+ return new Promise((resolve, reject) => {
767
+ this.client = mqtt.connect(this.brokerUrl, this.options);
768
+ this.client.on('connect', () => resolve());
769
+ this.client.on('error', (err) => reject(new Error(`[BytexMQTT] Connection failed: ${err.message}`)));
770
+ this.client.on('message', (topic, buf) => {
771
+ const msg = buf.toString();
772
+ let parsed; try { parsed = JSON.parse(msg); } catch { parsed = msg; }
773
+ this._handlers.forEach((fn, pattern) => {
774
+ if (this._topicMatch(pattern, topic)) fn(topic, parsed);
775
+ });
776
+ });
777
+ });
778
+ }
779
+
780
+ /**
781
+ * Publish a message to a topic.
782
+ * @param {string} topic
783
+ * @param {object|string} payload
784
+ * @param {{ qos?: 0|1|2, retain?: boolean }} options
785
+ */
786
+ publish(topic, payload, options = {}) {
787
+ if (!this.client) throw new Error('[BytexMQTT] Not connected. Call connect() first.');
788
+ const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
789
+ this.client.publish(topic, msg, { qos: options.qos ?? 1, retain: options.retain ?? false });
790
+ }
791
+
792
+ /**
793
+ * Subscribe to a topic or wildcard pattern.
794
+ * @param {string} topic - Supports MQTT wildcards: '#' (multi-level), '+' (single-level)
795
+ * @param {function} callback - (topic, parsedPayload) => void
796
+ * @param {{ qos?: 0|1|2 }} options
797
+ */
798
+ subscribe(topic, callback, options = {}) {
799
+ if (!this.client) throw new Error('[BytexMQTT] Not connected. Call connect() first.');
800
+ this.client.subscribe(topic, { qos: options.qos ?? 1 });
801
+ this._handlers.set(topic, callback);
802
+ }
803
+
804
+ /** Unsubscribe from a topic. */
805
+ unsubscribe(topic) {
806
+ if (this.client) this.client.unsubscribe(topic);
807
+ this._handlers.delete(topic);
808
+ }
809
+
810
+ /** Gracefully disconnect from the broker. */
811
+ disconnect() {
812
+ if (this.client) { this.client.end(); this.client = null; }
813
+ }
814
+
815
+ _topicMatch(pattern, topic) {
816
+ const p = pattern.replace(/\+/g, '[^/]+').replace(/#$/, '.*');
817
+ return new RegExp(`^${p}$`).test(topic);
818
+ }
819
+ }
820
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytex-sdk",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "description": "Official ByteX Cloud SDK with AI (BYOM) support",
5
5
  "main": "index.js",
6
6
  "scripts": {