zyro-gateway 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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/zyro-gateway.js +25 -0
- package/dist/zyro.js +395 -0
- package/package.json +76 -0
- package/scripts/build-zyro.js +22 -0
- package/scripts/init-config.js +21 -0
- package/scripts/load-zyro-config.js +4 -0
- package/server.js +4 -0
- package/src/config/load-config.js +68 -0
- package/src/config/pairing.js +21 -0
- package/src/index.js +19 -0
- package/src/server/broadcast.js +42 -0
- package/src/server/create-gateway.js +123 -0
- package/src/server/devices.js +49 -0
- package/src/server/rooms.js +71 -0
- package/src/server/routes.js +191 -0
- package/src/server/socket.js +115 -0
- package/src/server/terminal.js +49 -0
- package/src/utils/format.js +26 -0
- package/src/utils/network.js +17 -0
- package/zyro/browser-entry.js +1 -0
- package/zyro/zyro.js +415 -0
- package/zyro.config.example.js +22 -0
package/zyro/zyro.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zyro web client — use Zyro.connect({ serverUrl, pairingCode, ... }) on any site.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULTS = {
|
|
6
|
+
ip: '',
|
|
7
|
+
port: 3000,
|
|
8
|
+
serverUrl: '',
|
|
9
|
+
pairingCode: '',
|
|
10
|
+
role: 'desktop',
|
|
11
|
+
deviceName: 'Web Client',
|
|
12
|
+
autoConnect: true,
|
|
13
|
+
pollIntervalMs: 1500,
|
|
14
|
+
maxItems: 200,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function resolveBaseUrl(config) {
|
|
18
|
+
if (config.serverUrl) return normalizeUrl(config.serverUrl);
|
|
19
|
+
const ip = String(config.ip || '').trim();
|
|
20
|
+
const port = Number(config.port) || 3000;
|
|
21
|
+
if (!ip) {
|
|
22
|
+
if (typeof location !== 'undefined') {
|
|
23
|
+
return normalizeUrl(`http://${location.hostname}:${port}`);
|
|
24
|
+
}
|
|
25
|
+
throw new Error('Zyro: set ip and port in zyro.config.js');
|
|
26
|
+
}
|
|
27
|
+
return normalizeUrl(`http://${ip}:${port}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EVENTS = {
|
|
31
|
+
READY: 'ready',
|
|
32
|
+
STATUS: 'status',
|
|
33
|
+
ERROR: 'error',
|
|
34
|
+
TRANSACTION: 'transaction',
|
|
35
|
+
NOTIFICATION: 'notification',
|
|
36
|
+
DASHBOARD: 'dashboard',
|
|
37
|
+
PRESENCE: 'presence',
|
|
38
|
+
DEVICES: 'devices',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function normalizeUrl(url) {
|
|
42
|
+
let u = String(url || '').trim().replace(/\/+$/, '');
|
|
43
|
+
if (!u && typeof location !== 'undefined') return location.origin;
|
|
44
|
+
if (u.startsWith('ws://')) u = `http://${u.slice(5)}`;
|
|
45
|
+
if (u.startsWith('wss://')) u = `https://${u.slice(6)}`;
|
|
46
|
+
return u;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizePairing(raw) {
|
|
50
|
+
const p = String(raw || '')
|
|
51
|
+
.trim()
|
|
52
|
+
.toUpperCase()
|
|
53
|
+
.replace(/[^A-Z0-9]/g, '');
|
|
54
|
+
if (p.length < 4 || p.length > 12) return '';
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function txKey(tx) {
|
|
59
|
+
if (tx.id) return String(tx.id);
|
|
60
|
+
return `${tx.transactionNumber || tx.referenceNumber || ''}|${tx.timestamp || tx.receivedAt}|${tx.amount}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function noteKey(n) {
|
|
64
|
+
if (n.id) return String(n.id);
|
|
65
|
+
return `${n.timestamp || n.receivedAt}|${n.preview}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ZyroConnection {
|
|
69
|
+
constructor(options = {}) {
|
|
70
|
+
this.config = { ...DEFAULTS, ...options };
|
|
71
|
+
this._handlers = new Map();
|
|
72
|
+
this._socket = null;
|
|
73
|
+
this._baseUrl = '';
|
|
74
|
+
this._pairingCode = '';
|
|
75
|
+
this._status = 'idle';
|
|
76
|
+
this._pollTimers = [];
|
|
77
|
+
this.transactions = [];
|
|
78
|
+
this.notifications = [];
|
|
79
|
+
this.devices = new Map();
|
|
80
|
+
this.dashboard = null;
|
|
81
|
+
this.presence = { phones: 0, desktops: 0 };
|
|
82
|
+
this._seenTx = new Set();
|
|
83
|
+
this._seenNotes = new Set();
|
|
84
|
+
this._lastTxTime = '';
|
|
85
|
+
this._lastNoteTime = '';
|
|
86
|
+
|
|
87
|
+
if (this.config.autoConnect) {
|
|
88
|
+
this.connect().catch((err) => this._emit(EVENTS.ERROR, err));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
on(event, fn) {
|
|
93
|
+
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
|
|
94
|
+
this._handlers.get(event).add(fn);
|
|
95
|
+
return () => this.off(event, fn);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
off(event, fn) {
|
|
99
|
+
this._handlers.get(event)?.delete(fn);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_emit(event, ...args) {
|
|
103
|
+
const set = this._handlers.get(event);
|
|
104
|
+
if (!set) return;
|
|
105
|
+
for (const fn of [...set]) fn(...args);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_setStatus(status, detail = {}) {
|
|
109
|
+
this._status = status;
|
|
110
|
+
this._emit(EVENTS.STATUS, { status, ...detail });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async connect() {
|
|
114
|
+
this._baseUrl = resolveBaseUrl(this.config);
|
|
115
|
+
this._setStatus('connecting');
|
|
116
|
+
|
|
117
|
+
const infoRes = await fetch(`${this._baseUrl}/api/info`, { cache: 'no-store' });
|
|
118
|
+
if (!infoRes.ok) throw new Error(`Zyro: cannot reach server (${infoRes.status})`);
|
|
119
|
+
|
|
120
|
+
const configured = normalizePairing(this.config.pairingCode);
|
|
121
|
+
if (!configured) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'Zyro: pairingCode is required in zyro.config.js (4–12 letters/numbers)',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
this._pairingCode = configured;
|
|
127
|
+
|
|
128
|
+
const info = await infoRes.json();
|
|
129
|
+
const cfgPort = Number(this.config.port) || 3000;
|
|
130
|
+
if (info.port && info.port !== cfgPort) {
|
|
131
|
+
console.warn(
|
|
132
|
+
`Zyro: server port is ${info.port} but zyro.config.js port is ${cfgPort} — fix port and restart npm start`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (info.ip && !this.config.ip) {
|
|
136
|
+
this.config.ip = info.ip;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this._connectSocket();
|
|
140
|
+
this._startPollers();
|
|
141
|
+
|
|
142
|
+
if (typeof document !== 'undefined') {
|
|
143
|
+
document.addEventListener('visibilitychange', () => {
|
|
144
|
+
if (document.visibilityState === 'visible') {
|
|
145
|
+
this._pollTransactions();
|
|
146
|
+
this._pollNotifications();
|
|
147
|
+
this._refreshDashboard();
|
|
148
|
+
if (this._socket && !this._socket.connected) this._socket.connect();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { pairingCode: this._pairingCode, serverUrl: this._baseUrl };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async setPairingCode(code) {
|
|
157
|
+
const next = normalizePairing(code);
|
|
158
|
+
if (!next) throw new Error('Zyro: invalid pairing code (4–12 letters/numbers)');
|
|
159
|
+
this.config.pairingCode = next;
|
|
160
|
+
this.disconnect();
|
|
161
|
+
this._seenTx.clear();
|
|
162
|
+
this._seenNotes.clear();
|
|
163
|
+
this._lastTxTime = '';
|
|
164
|
+
this._lastNoteTime = '';
|
|
165
|
+
this.transactions.length = 0;
|
|
166
|
+
this.notifications.length = 0;
|
|
167
|
+
this.devices.clear();
|
|
168
|
+
return this.connect();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_pairingQuery(extra = {}) {
|
|
172
|
+
const params = new URLSearchParams({ pairing: this._pairingCode, ...extra });
|
|
173
|
+
return `?${params.toString()}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_fetchOpts() {
|
|
177
|
+
return {
|
|
178
|
+
cache: 'no-store',
|
|
179
|
+
headers: { 'X-Pairing': this._pairingCode },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
disconnect() {
|
|
184
|
+
this._stopPollers();
|
|
185
|
+
if (this._socket) {
|
|
186
|
+
this._socket.removeAllListeners();
|
|
187
|
+
this._socket.disconnect();
|
|
188
|
+
this._socket = null;
|
|
189
|
+
}
|
|
190
|
+
this._setStatus('disconnected');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_connectSocket() {
|
|
194
|
+
const io = typeof globalThis !== 'undefined' ? globalThis.io : null;
|
|
195
|
+
if (!io) {
|
|
196
|
+
this._setStatus('polling', { reason: 'socket.io not loaded — using HTTP poll only' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (this._socket) {
|
|
200
|
+
this._socket.removeAllListeners();
|
|
201
|
+
this._socket.disconnect();
|
|
202
|
+
}
|
|
203
|
+
this._socket = io(this._baseUrl, {
|
|
204
|
+
query: {
|
|
205
|
+
pairing: this._pairingCode,
|
|
206
|
+
role: this.config.role,
|
|
207
|
+
deviceName: this.config.deviceName,
|
|
208
|
+
},
|
|
209
|
+
transports: ['websocket', 'polling'],
|
|
210
|
+
reconnection: true,
|
|
211
|
+
reconnectionAttempts: Infinity,
|
|
212
|
+
reconnectionDelay: 1000,
|
|
213
|
+
reconnectionDelayMax: 5000,
|
|
214
|
+
timeout: 20000,
|
|
215
|
+
});
|
|
216
|
+
this._socket.on('connect', () => {
|
|
217
|
+
this._setStatus('connected');
|
|
218
|
+
this._pollTransactions();
|
|
219
|
+
this._pollNotifications();
|
|
220
|
+
this._refreshDashboard();
|
|
221
|
+
});
|
|
222
|
+
this._socket.on('disconnect', () => this._setStatus('reconnecting'));
|
|
223
|
+
this._socket.on('connect_error', () => this._setStatus('reconnecting'));
|
|
224
|
+
this._socket.io.on('reconnect', () => {
|
|
225
|
+
this._pollTransactions();
|
|
226
|
+
this._pollNotifications();
|
|
227
|
+
this._refreshDashboard();
|
|
228
|
+
});
|
|
229
|
+
this._socket.on('sync_ready', (data) => {
|
|
230
|
+
this._emit(EVENTS.READY, data);
|
|
231
|
+
this._pollTransactions();
|
|
232
|
+
this._pollNotifications();
|
|
233
|
+
this._refreshDashboard();
|
|
234
|
+
});
|
|
235
|
+
this._socket.on('presence', (data) => {
|
|
236
|
+
this.presence = data;
|
|
237
|
+
if (Array.isArray(data.devices)) this._mergeDevices(data.devices);
|
|
238
|
+
this._emit(EVENTS.PRESENCE, data);
|
|
239
|
+
this._emit(EVENTS.DEVICES, this.getDevices());
|
|
240
|
+
});
|
|
241
|
+
this._socket.on('device_joined', (d) => {
|
|
242
|
+
this._upsertDevice(d);
|
|
243
|
+
this._emit(EVENTS.DEVICES, this.getDevices());
|
|
244
|
+
});
|
|
245
|
+
this._socket.on('device_updated', (d) => {
|
|
246
|
+
this._upsertDevice(d);
|
|
247
|
+
this._emit(EVENTS.DEVICES, this.getDevices());
|
|
248
|
+
});
|
|
249
|
+
this._socket.on('device_left', ({ id }) => {
|
|
250
|
+
this.devices.delete(id);
|
|
251
|
+
this._emit(EVENTS.DEVICES, this.getDevices());
|
|
252
|
+
});
|
|
253
|
+
this._socket.on('history', (list) => this._mergeTxList(list));
|
|
254
|
+
this._socket.on('notification_history', (list) => this._mergeNoteList(list));
|
|
255
|
+
this._socket.on('income_transaction', (tx) => {
|
|
256
|
+
if (this._addTransaction(tx)) this._emit(EVENTS.TRANSACTION, tx);
|
|
257
|
+
});
|
|
258
|
+
this._socket.on('notification_event', (note) => {
|
|
259
|
+
if (this._addNotification(note)) this._emit(EVENTS.NOTIFICATION, note);
|
|
260
|
+
});
|
|
261
|
+
this._socket.on('dashboard_update', (payload) => {
|
|
262
|
+
this.dashboard = payload;
|
|
263
|
+
this._emit(EVENTS.DASHBOARD, payload);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_startPollers() {
|
|
268
|
+
this._stopPollers();
|
|
269
|
+
const ms = this.config.pollIntervalMs;
|
|
270
|
+
this._pollTransactions();
|
|
271
|
+
this._pollNotifications();
|
|
272
|
+
this._refreshDashboard();
|
|
273
|
+
this._pollTimers.push(setInterval(() => this._pollTransactions(), ms));
|
|
274
|
+
this._pollTimers.push(setInterval(() => this._pollNotifications(), ms));
|
|
275
|
+
this._pollTimers.push(setInterval(() => this._refreshDashboard(), ms * 2));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_stopPollers() {
|
|
279
|
+
for (const t of this._pollTimers) clearInterval(t);
|
|
280
|
+
this._pollTimers = [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async _pollTransactions() {
|
|
284
|
+
try {
|
|
285
|
+
const extra = this._lastTxTime ? { after: this._lastTxTime } : {};
|
|
286
|
+
const data = await fetch(
|
|
287
|
+
`${this._baseUrl}/api/transactions${this._pairingQuery(extra)}`,
|
|
288
|
+
this._fetchOpts(),
|
|
289
|
+
).then((r) => r.json());
|
|
290
|
+
for (const tx of data.transactions || []) {
|
|
291
|
+
if (this._addTransaction(tx)) this._emit(EVENTS.TRANSACTION, tx);
|
|
292
|
+
}
|
|
293
|
+
} catch (_) {
|
|
294
|
+
/* ignore poll errors */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async _pollNotifications() {
|
|
299
|
+
try {
|
|
300
|
+
const extra = this._lastNoteTime ? { after: this._lastNoteTime } : {};
|
|
301
|
+
const data = await fetch(
|
|
302
|
+
`${this._baseUrl}/api/notifications${this._pairingQuery(extra)}`,
|
|
303
|
+
this._fetchOpts(),
|
|
304
|
+
).then((r) => r.json());
|
|
305
|
+
for (const note of data.notifications || []) {
|
|
306
|
+
if (this._addNotification(note)) this._emit(EVENTS.NOTIFICATION, note);
|
|
307
|
+
}
|
|
308
|
+
} catch (_) {
|
|
309
|
+
/* ignore poll errors */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async _refreshDashboard() {
|
|
314
|
+
try {
|
|
315
|
+
const data = await fetch(
|
|
316
|
+
`${this._baseUrl}/api/dashboard${this._pairingQuery()}`,
|
|
317
|
+
this._fetchOpts(),
|
|
318
|
+
).then((r) => r.json());
|
|
319
|
+
this.dashboard = data;
|
|
320
|
+
if (data.stats) {
|
|
321
|
+
this.presence = {
|
|
322
|
+
phones: data.stats.phones,
|
|
323
|
+
desktops: data.stats.desktops,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
this._emit(EVENTS.DASHBOARD, data);
|
|
327
|
+
} catch (_) {
|
|
328
|
+
/* ignore */
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const data = await fetch(
|
|
332
|
+
`${this._baseUrl}/api/devices${this._pairingQuery()}`,
|
|
333
|
+
this._fetchOpts(),
|
|
334
|
+
).then((r) => r.json());
|
|
335
|
+
if (Array.isArray(data.devices)) {
|
|
336
|
+
this.devices.clear();
|
|
337
|
+
this._mergeDevices(data.devices);
|
|
338
|
+
this._emit(EVENTS.DEVICES, this.getDevices());
|
|
339
|
+
}
|
|
340
|
+
} catch (_) {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_addTransaction(tx) {
|
|
346
|
+
const key = txKey(tx);
|
|
347
|
+
if (this._seenTx.has(key)) return false;
|
|
348
|
+
this._seenTx.add(key);
|
|
349
|
+
const t = String(tx.receivedAt || tx.timestamp || '');
|
|
350
|
+
if (t && t > this._lastTxTime) this._lastTxTime = t;
|
|
351
|
+
this.transactions.unshift(tx);
|
|
352
|
+
while (this.transactions.length > this.config.maxItems) {
|
|
353
|
+
const removed = this.transactions.pop();
|
|
354
|
+
this._seenTx.delete(txKey(removed));
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_mergeTxList(list) {
|
|
360
|
+
if (!Array.isArray(list)) return;
|
|
361
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
362
|
+
if (this._addTransaction(list[i])) this._emit(EVENTS.TRANSACTION, list[i]);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_addNotification(note) {
|
|
367
|
+
const key = noteKey(note);
|
|
368
|
+
if (this._seenNotes.has(key)) return false;
|
|
369
|
+
this._seenNotes.add(key);
|
|
370
|
+
const t = String(note.receivedAt || note.timestamp || '');
|
|
371
|
+
if (t && t > this._lastNoteTime) this._lastNoteTime = t;
|
|
372
|
+
this.notifications.unshift(note);
|
|
373
|
+
while (this.notifications.length > this.config.maxItems) {
|
|
374
|
+
const removed = this.notifications.pop();
|
|
375
|
+
this._seenNotes.delete(noteKey(removed));
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_mergeNoteList(list) {
|
|
381
|
+
if (!Array.isArray(list)) return;
|
|
382
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
383
|
+
if (this._addNotification(list[i])) this._emit(EVENTS.NOTIFICATION, list[i]);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_upsertDevice(d) {
|
|
388
|
+
if (!d?.id) return;
|
|
389
|
+
this.devices.set(d.id, { ...this.devices.get(d.id), ...d, online: true });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_mergeDevices(list) {
|
|
393
|
+
for (const d of list) this._upsertDevice(d);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
getDevices() {
|
|
397
|
+
return Array.from(this.devices.values());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
get isConnected() {
|
|
401
|
+
return this._status === 'connected';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
get pairingCode() {
|
|
405
|
+
return this._pairingCode;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
get serverUrl() {
|
|
409
|
+
return this._baseUrl;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function connect(options) {
|
|
414
|
+
return new ZyroConnection(options);
|
|
415
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zyro Gateway config — create in your project:
|
|
3
|
+
* npx zyro-gateway config
|
|
4
|
+
*
|
|
5
|
+
* Loaded from (first match):
|
|
6
|
+
* 1. ZYRO_CONFIG env path
|
|
7
|
+
* 2. ./zyro.config.js in the directory you start the server from
|
|
8
|
+
* 3. zyro.config.js next to this package
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
/** PC LAN IP — leave '' to auto-detect (shown in terminal on start) */
|
|
13
|
+
ip: '',
|
|
14
|
+
/** Gateway port (phone app + website must match) */
|
|
15
|
+
port: 3001,
|
|
16
|
+
/** Same code in your phone app Setup → Zyro Gateway */
|
|
17
|
+
pairingCode: 'MYSTORE',
|
|
18
|
+
/** Label for web clients (optional) */
|
|
19
|
+
deviceName: 'My Website',
|
|
20
|
+
autoConnect: true,
|
|
21
|
+
pollIntervalMs: 1500,
|
|
22
|
+
};
|