elpaypg 2.0.6 → 2.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elpaypg",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "ELPAY PG - single-backend payment gateway module",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -970,19 +970,17 @@
970
970
  let currentSelectedMethod = 'UPI';
971
971
 
972
972
  function hideAllContent() {
973
- document.getElementById('viewUpiContent').hidden = true;
974
- const vCod = document.getElementById('viewCodContent');
975
- if (vCod) vCod.hidden = true;
976
- const vCash = document.getElementById('viewCashContent');
977
- if (vCash) vCash.hidden = true;
978
- const vCard = document.getElementById('viewCardContent');
979
- if (vCard) vCard.hidden = true;
980
- document.getElementById('viewProcessingContent').hidden = true;
981
- document.getElementById('viewSuccessContent').hidden = true;
982
- document.getElementById('viewFailedContent').hidden = true;
983
- document.getElementById('viewQueuedContent').hidden = true;
984
- const vBlocked = document.getElementById('viewBlockedContent');
985
- if (vBlocked) vBlocked.hidden = true;
973
+ const u = document.getElementById('viewUpiContent'); if (u) u.hidden = true;
974
+ const vCod = document.getElementById('viewCodContent'); if (vCod) vCod.hidden = true;
975
+ const vCash = document.getElementById('viewCashContent'); if (vCash) vCash.hidden = true;
976
+ const vCard = document.getElementById('viewCardContent'); if (vCard) vCard.hidden = true;
977
+ const p = document.getElementById('viewProcessingContent'); if (p) p.hidden = true;
978
+ const s = document.getElementById('viewSuccessContent'); if (s) s.hidden = true;
979
+ const f = document.getElementById('viewFailedContent'); if (f) f.hidden = true;
980
+ const q = document.getElementById('viewQueuedContent'); if (q) q.hidden = true;
981
+ const vBlocked = document.getElementById('viewBlockedContent'); if (vBlocked) vBlocked.hidden = true;
982
+ const vDup = document.getElementById('viewDuplicateSessionContent'); if (vDup) vDup.hidden = true;
983
+ }
986
984
  const vDup = document.getElementById('viewDuplicateSessionContent');
987
985
  if (vDup) vDup.hidden = true;
988
986
  }
@@ -1432,6 +1430,21 @@
1432
1430
  }
1433
1431
 
1434
1432
  return { deviceId: null, deviceToken: null };
1433
+ },
1434
+
1435
+ async reset() {
1436
+ this.currentDeviceId = null;
1437
+ this.currentDeviceToken = null;
1438
+ try { localStorage.removeItem('ypg_device_id'); localStorage.removeItem('ypg_device_token'); } catch {}
1439
+ try {
1440
+ const db = await this.openDb();
1441
+ if (db) {
1442
+ const tx = db.transaction(this.storeName, 'readwrite');
1443
+ const store = tx.objectStore(this.storeName);
1444
+ store.delete('primary_device');
1445
+ await new Promise((resolve) => { tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); });
1446
+ }
1447
+ } catch {}
1435
1448
  }
1436
1449
  };
1437
1450
 
@@ -1459,6 +1472,14 @@
1459
1472
  const res = await fetch(`${API_BASE}/api/v1/checkout/${PAYMENT_ID}/state`, { headers });
1460
1473
  const data = await res.json().catch(() => ({}));
1461
1474
 
1475
+ if (data.code === 'DEVICE_REVOKED' || data.code === 'DEVICE_TOKEN_INVALID' || data.code === 'DEVICE_ID_REQUIRED' || data.code === 'DEVICE_CREDENTIALS_REQUIRED') {
1476
+ if (!window._deviceResetAttempted) {
1477
+ window._deviceResetAttempted = true;
1478
+ await DeviceIdentity.reset();
1479
+ return loadState();
1480
+ }
1481
+ }
1482
+
1462
1483
  if (res.status === 409 || data.error === 'DUPLICATE_SESSION_DETECTED') {
1463
1484
  renderDuplicateSession(data);
1464
1485
  dismissSplashScreen();
package/src/index.js CHANGED
@@ -1,137 +1,138 @@
1
- import { EventEmitter } from 'node:events';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { PaymentStatus } from './vendor/common/index.js';
5
- import {
6
- getDatabase,
7
- ensureDatabaseInitialized,
8
- runMigrations,
9
- MerchantRepository,
10
- ReceivingAccountRepository,
11
- CustomerRepository,
12
- CustomerSessionRepository,
13
- PaymentSessionRepository,
14
- ObservationRepository,
15
- SupportTicketRepository,
16
- AuditLogRepository,
17
- DeviceRepository
18
- } from './vendor/db/index.js';
19
- import {
20
- QueueEngine,
21
- TimingEngine,
22
- AttributionEngine,
23
- RecoveryEngine,
24
- WebhookEngine,
25
- PaymentVerificationPipeline
26
- } from './vendor/core/index.js';
27
- import { createRouter } from './router.js';
28
-
29
- const __filename = fileURLToPath(import.meta.url);
30
- const __dirname = path.dirname(__filename);
31
-
32
- export async function elpay(options = {}) {
33
- const db = options.db || getDatabase(options.databaseUrl);
34
- ensureDatabaseInitialized(db);
35
- const { defaultMerchant } = runMigrations(db);
36
-
37
- const merchantRepo = new MerchantRepository(db);
38
- const accountRepo = new ReceivingAccountRepository(db);
39
- const customerRepo = new CustomerRepository(db);
40
- const customerSessionRepo = new CustomerSessionRepository(db, customerRepo);
41
- const sessionRepo = new PaymentSessionRepository(db);
42
- const obsRepo = new ObservationRepository(db);
43
- const ticketRepo = new SupportTicketRepository(db);
44
- const auditRepo = new AuditLogRepository(db);
45
- const deviceRepo = new DeviceRepository(db);
46
-
47
- const eventEmitter = new EventEmitter();
48
-
49
- const parseWindowMs = (raw, fallback) => {
50
- if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
51
- const value = Number(raw);
52
- if (!Number.isFinite(value) || value <= 0 || value > 24 * 60 * 60 * 1000) return fallback;
53
- return Math.floor(value);
54
- };
55
- const activeDurationMs = parseWindowMs(process.env.PAYMENT_ACTIVE_WINDOW_MS, 300000);
56
- const processingDurationMs = parseWindowMs(process.env.PAYMENT_PROCESSING_WINDOW_MS, 30000);
57
-
58
- const queueEngine = new QueueEngine({ sessionRepo, accountRepo, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
59
- const timingEngine = new TimingEngine({ sessionRepo, queueEngine, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
60
- const attributionEngine = new AttributionEngine({ sessionRepo, obsRepo, ticketRepo, auditRepo, eventEmitter });
61
- const recoveryEngine = new RecoveryEngine({ db, sessionRepo, accountRepo, queueEngine, timingEngine, auditRepo, eventEmitter });
62
- const webhookEngine = new WebhookEngine({ db, merchantRepo, maxRetries: Number(process.env.WEBHOOK_MAX_RETRIES || 5) });
63
-
64
- const checkoutBaseUrl = options.baseUrl || process.env.BASE_CHECKOUT_URL || 'http://localhost:3000';
65
-
66
- const handleSlotFreedAndPromote = (receivingAccountId) => {
67
- if (!receivingAccountId) return null;
68
- const promoted = queueEngine.promoteNext(receivingAccountId);
69
- if (promoted) {
70
- if (promoted.expiresAt) timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
71
- }
72
- return promoted;
73
- };
74
-
75
- eventEmitter.on('PAYMENT.SUCCESS', (data) => {
76
- const session = sessionRepo.findById(data.paymentId);
77
- if (session) {
78
- webhookEngine.dispatch('SUCCESS', session).catch(() => {});
79
- handleSlotFreedAndPromote(session.receivingAccountId);
80
- }
81
- if (options.onSuccess) options.onSuccess(data);
82
- });
83
- eventEmitter.on('PAYMENT.FAILED', (data) => {
84
- const session = sessionRepo.findById(data.paymentId);
85
- if (session) {
86
- webhookEngine.dispatch('FAILED', session).catch(() => {});
87
- handleSlotFreedAndPromote(session.receivingAccountId);
88
- }
89
- if (options.onFailed) options.onFailed(data);
90
- });
91
- eventEmitter.on('PAYMENT.EXPIRED', (data) => {
92
- const session = sessionRepo.findById(data.paymentId);
93
- if (session) {
94
- webhookEngine.dispatch('EXPIRED', session).catch(() => {});
95
- handleSlotFreedAndPromote(session.receivingAccountId);
96
- }
97
- });
98
- eventEmitter.on('PAYMENT.CANCELLED', (data) => {
99
- const session = sessionRepo.findById(data.paymentId);
100
- if (session) {
101
- webhookEngine.dispatch('CANCELLED', session).catch(() => {});
102
- handleSlotFreedAndPromote(session.receivingAccountId);
103
- }
104
- });
105
-
106
- recoveryEngine.recover();
107
-
108
- const pipeline = new PaymentVerificationPipeline({
109
- db, sessionRepo, obsRepo, customerRepo, attributionEngine, auditRepo, merchantRepo,
110
- server: null, eventEmitter
111
- });
112
-
113
- const ctx = {
114
- db, merchantRepo, accountRepo, customerRepo, customerSessionRepo, sessionRepo, deviceRepo,
115
- obsRepo, ticketRepo, auditRepo, queueEngine, timingEngine, attributionEngine, pipeline,
116
- recoveryEngine, eventEmitter, defaultMerchant, checkoutBaseUrl, webhookEngine,
117
- otpCooldownMs: options.otpCooldownMs,
118
- theme: options.theme || {}
119
- };
120
-
121
- const router = createRouter(ctx);
122
-
123
- const destroy = () => {
124
- timingEngine.destroy?.();
125
- attributionEngine.destroy?.();
126
- };
127
-
128
- return {
129
- router,
130
- on: (event, cb) => eventEmitter.on(event, cb),
131
- init: async () => {},
132
- destroy,
133
- };
134
- }
135
-
136
- export { createRouter };
137
- export default { elpay };
1
+ import { EventEmitter } from 'node:events';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { PaymentStatus } from './vendor/common/index.js';
5
+ import {
6
+ getDatabase,
7
+ ensureDatabaseInitialized,
8
+ runMigrations,
9
+ MerchantRepository,
10
+ ReceivingAccountRepository,
11
+ CustomerRepository,
12
+ CustomerSessionRepository,
13
+ PaymentSessionRepository,
14
+ ObservationRepository,
15
+ SupportTicketRepository,
16
+ AuditLogRepository,
17
+ DeviceRepository
18
+ } from './vendor/db/index.js';
19
+ import {
20
+ QueueEngine,
21
+ TimingEngine,
22
+ AttributionEngine,
23
+ RecoveryEngine,
24
+ WebhookEngine,
25
+ PaymentVerificationPipeline
26
+ } from './vendor/core/index.js';
27
+ import { createRouter } from './router.js';
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+
32
+ export async function elpay(options = {}) {
33
+ const db = options.db || getDatabase(options.databaseUrl);
34
+ ensureDatabaseInitialized(db);
35
+ const { defaultMerchant } = runMigrations(db);
36
+
37
+ const merchantRepo = new MerchantRepository(db);
38
+ const accountRepo = new ReceivingAccountRepository(db);
39
+ const customerRepo = new CustomerRepository(db);
40
+ const customerSessionRepo = new CustomerSessionRepository(db, customerRepo);
41
+ const sessionRepo = new PaymentSessionRepository(db);
42
+ const obsRepo = new ObservationRepository(db);
43
+ const ticketRepo = new SupportTicketRepository(db);
44
+ const auditRepo = new AuditLogRepository(db);
45
+ const deviceRepo = new DeviceRepository(db);
46
+
47
+ const eventEmitter = new EventEmitter();
48
+
49
+ const parseWindowMs = (raw, fallback) => {
50
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
51
+ const value = Number(raw);
52
+ if (!Number.isFinite(value) || value <= 0 || value > 24 * 60 * 60 * 1000) return fallback;
53
+ return Math.floor(value);
54
+ };
55
+ const activeDurationMs = parseWindowMs(process.env.PAYMENT_ACTIVE_WINDOW_MS, 300000);
56
+ const processingDurationMs = parseWindowMs(process.env.PAYMENT_PROCESSING_WINDOW_MS, 30000);
57
+
58
+ const queueEngine = new QueueEngine({ sessionRepo, accountRepo, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
59
+ const timingEngine = new TimingEngine({ sessionRepo, queueEngine, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
60
+ const attributionEngine = new AttributionEngine({ sessionRepo, obsRepo, ticketRepo, auditRepo, eventEmitter });
61
+ const recoveryEngine = new RecoveryEngine({ db, sessionRepo, accountRepo, queueEngine, timingEngine, auditRepo, eventEmitter });
62
+ const webhookEngine = new WebhookEngine({ db, merchantRepo, maxRetries: Number(process.env.WEBHOOK_MAX_RETRIES || 5) });
63
+
64
+ const checkoutBaseUrl = options.baseUrl || process.env.BASE_CHECKOUT_URL || 'http://localhost:3000';
65
+
66
+ const handleSlotFreedAndPromote = (receivingAccountId) => {
67
+ if (!receivingAccountId) return null;
68
+ const promoted = queueEngine.promoteNext(receivingAccountId);
69
+ if (promoted) {
70
+ if (promoted.expiresAt) timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
71
+ }
72
+ return promoted;
73
+ };
74
+
75
+ eventEmitter.on('PAYMENT.SUCCESS', (data) => {
76
+ const session = sessionRepo.findById(data.paymentId);
77
+ if (session) {
78
+ webhookEngine.dispatch('SUCCESS', session).catch(() => {});
79
+ handleSlotFreedAndPromote(session.receivingAccountId);
80
+ }
81
+ if (options.onSuccess) options.onSuccess(data);
82
+ });
83
+ eventEmitter.on('PAYMENT.FAILED', (data) => {
84
+ const session = sessionRepo.findById(data.paymentId);
85
+ if (session) {
86
+ webhookEngine.dispatch('FAILED', session).catch(() => {});
87
+ handleSlotFreedAndPromote(session.receivingAccountId);
88
+ }
89
+ if (options.onFailed) options.onFailed(data);
90
+ });
91
+ eventEmitter.on('PAYMENT.EXPIRED', (data) => {
92
+ const session = sessionRepo.findById(data.paymentId);
93
+ if (session) {
94
+ webhookEngine.dispatch('EXPIRED', session).catch(() => {});
95
+ handleSlotFreedAndPromote(session.receivingAccountId);
96
+ }
97
+ });
98
+ eventEmitter.on('PAYMENT.CANCELLED', (data) => {
99
+ const session = sessionRepo.findById(data.paymentId);
100
+ if (session) {
101
+ webhookEngine.dispatch('CANCELLED', session).catch(() => {});
102
+ handleSlotFreedAndPromote(session.receivingAccountId);
103
+ }
104
+ });
105
+
106
+ recoveryEngine.recover();
107
+
108
+ const pipeline = new PaymentVerificationPipeline({
109
+ db, sessionRepo, obsRepo, customerRepo, attributionEngine, auditRepo, merchantRepo,
110
+ server: null, eventEmitter
111
+ });
112
+
113
+ const ctx = {
114
+ db, merchantRepo, accountRepo, customerRepo, customerSessionRepo, sessionRepo, deviceRepo,
115
+ obsRepo, ticketRepo, auditRepo, queueEngine, timingEngine, attributionEngine, pipeline,
116
+ recoveryEngine, eventEmitter, defaultMerchant, checkoutBaseUrl, webhookEngine,
117
+ baseUrl: options.baseUrl || process.env.BASE_URL || 'http://localhost:5000',
118
+ otpCooldownMs: options.otpCooldownMs,
119
+ theme: options.theme || {}
120
+ };
121
+
122
+ const router = createRouter(ctx);
123
+
124
+ const destroy = () => {
125
+ timingEngine.destroy?.();
126
+ attributionEngine.destroy?.();
127
+ };
128
+
129
+ return {
130
+ router,
131
+ on: (event, cb) => eventEmitter.on(event, cb),
132
+ init: async () => {},
133
+ destroy,
134
+ };
135
+ }
136
+
137
+ export { createRouter };
138
+ export default { elpay };
package/src/router.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Router } from 'express';
2
+ import { server } from './sse.js';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import fs from 'node:fs';
5
6
  import express from 'express';
7
+ import { getInternalServiceToken } from './vendor/common/index.js';
6
8
  import { registerPaymentRoutes } from './routes/payments.routes.js';
7
9
  import { registerCheckoutRoutes } from './routes/checkout.routes.js';
8
10
  import { registerPollerRoutes } from './routes/pollers.routes.js';
@@ -12,15 +14,60 @@ import { registerDeviceRoutes } from './routes/device.routes.js';
12
14
 
13
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
16
 
17
+ function loadTemplateWithPartials() {
18
+ const viewsDir = path.resolve(__dirname, '..', 'public', 'views');
19
+ const partialsDir = path.resolve(viewsDir, 'partials');
20
+ let content = fs.readFileSync(path.join(viewsDir, 'checkout.html'), 'utf8');
21
+ content = content.replace(/<!--\s*include:partials\/([a-zA-Z0-9_\-.]+)\s*-->/g, (match, filename) => {
22
+ const partialPath = path.resolve(partialsDir, filename);
23
+ const relative = path.relative(partialsDir, partialPath);
24
+ const isSecure = relative && !relative.startsWith('..') && !path.isAbsolute(relative);
25
+ if (isSecure && fs.existsSync(partialPath)) {
26
+ return fs.readFileSync(partialPath, 'utf8');
27
+ }
28
+ return match;
29
+ });
30
+ return content;
31
+ }
32
+
33
+ function renderTemplate(template, values) {
34
+ const escapeHtml = (v) => (v === null || v === undefined) ? '' : String(v).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
35
+ const escapeJs = (v) => (v === null || v === undefined) ? '' : String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
36
+ const scriptKeys = new Set(['PAYMENT_ID', 'API_BASE_URL', 'AMOUNT']);
37
+ return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
38
+ if (!Object.prototype.hasOwnProperty.call(values, key)) return match;
39
+ const value = values[key];
40
+ return scriptKeys.has(key) ? escapeJs(value) : escapeHtml(value);
41
+ });
42
+ }
43
+
44
+ function serializeForScript(value) {
45
+ return JSON.stringify(value === undefined ? null : value)
46
+ .replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
47
+ .replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
48
+ }
49
+
50
+ async function fetchCheckoutState(ctx, paymentId) {
51
+ try {
52
+ const base = (ctx.baseUrl || ctx.checkoutBaseUrl || 'http://127.0.0.1:5000').replace(/\/$/, '');
53
+ const token = getInternalServiceToken();
54
+ const res = await fetch(`${base}/api/v1/checkout/${encodeURIComponent(paymentId)}/state`, {
55
+ headers: { Authorization: `Bearer ${token}`, 'X-Internal-Render': 'true' },
56
+ signal: AbortSignal.timeout(4000)
57
+ });
58
+ if (res.ok) return await res.json();
59
+ } catch {}
60
+ return null;
61
+ }
62
+
15
63
  export function createRouter(ctx = {}) {
16
64
  const router = Router();
17
65
  router.use(express.static(path.join(__dirname, '..', 'public')));
18
66
 
19
- router.get('/checkout/:id', (req, res) => {
20
- const htmlPath = path.join(__dirname, '..', 'public', 'views', 'checkout.html');
21
- const html = fs.readFileSync(htmlPath, 'utf8')
22
- .replace(/\{\{API_BASE_URL\}\}/g, ctx.baseUrl || 'http://localhost:5000')
23
- .replace(/\{\{PAYMENT_ID\}\}/g, req.params.id);
67
+ router.get('/checkout/:id', async (req, res) => {
68
+ const paymentId = req.params.id;
69
+ const state = await fetchCheckoutState(ctx, paymentId);
70
+ const html = renderCheckoutPage(ctx, paymentId, state);
24
71
  res.type('html').send(html);
25
72
  });
26
73
 
@@ -50,4 +97,17 @@ export function createRouter(ctx = {}) {
50
97
  return router;
51
98
  }
52
99
 
100
+ function renderCheckoutPage(ctx, paymentId, state) {
101
+ const template = loadTemplateWithPartials();
102
+ const values = {
103
+ PAYMENT_ID: paymentId,
104
+ API_BASE_URL: '',
105
+ APP_NAME: state?.branding?.appName || 'ELPAY PG',
106
+ MERCHANT_NAME: state?.merchantName || 'Merchant Checkout',
107
+ AMOUNT: state?.amount || '0.00',
108
+ INITIAL_STATE_JSON: serializeForScript(state || {})
109
+ };
110
+ return renderTemplate(template, values);
111
+ }
112
+
53
113
  export default createRouter;
@@ -1,4 +1,5 @@
1
- import crypto from 'node:crypto';
1
+ import crypto from 'node:crypto';
2
+ import { server } from '../sse.js';
2
3
  import { Router } from 'express';
3
4
  import { PaymentStatus, PaymentMethod, SellingMode, AuditActorType, isValidVpa, toRupeesString, deriveCloudflareRoutes, resolveRuntimeUrls, resolveGmailRedirectUri, createOAuthState, validateOutboundWebhookUrl } from '../vendor/common/index.js';
4
5
  import { PaymentVerificationPipeline } from '../vendor/core/index.js';
@@ -1120,10 +1121,7 @@ export function registerAdminRoutes(router, ctx) {
1120
1121
 
1121
1122
  // GET /api/v1/admin/stream (SSE for Live Admin Counter & Queue Events)
1122
1123
  router.get('/api/v1/admin/stream', async (req, res) => {
1123
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
1124
- res.write('event: connected\ndata: {"status":"connected"}\n\n');
1125
- const _iv = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1126
- req.on('close', () => clearInterval(_iv));
1124
+ server.initSse('admin_counter_events', res);
1127
1125
  });
1128
1126
 
1129
1127
  // --- WHATSAPP BERRY PROTOCOL MERCHANT SESSION CONFIGURATION ---
@@ -1,4 +1,5 @@
1
- import { Router } from 'express';
1
+ import { Router } from 'express';
2
+ import { server } from '../sse.js';
2
3
  import { toRupeesString, buildUpiUri, PaymentStatus, PaymentMethod, UPI_PROVIDERS, isInternalServiceRequest, isValidTransition } from '../vendor/common/index.js';
3
4
  import { validateSessionDeviceAccess, verifyRegisteredDeviceCredentials } from '../vendor/core/index.js';
4
5
  import { isPaymentOwner } from '../auth-guard.js';
@@ -1961,15 +1962,7 @@ export function registerCheckoutRoutes(router, ctx) {
1961
1962
  res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1962
1963
  return;
1963
1964
  }
1964
- res.writeHead(200, {
1965
- 'Content-Type': 'text/event-stream',
1966
- 'Cache-Control': 'no-cache',
1967
- 'Connection': 'keep-alive',
1968
- 'Access-Control-Allow-Origin': '*'
1969
- });
1970
- res.write('event: connected\ndata: {"status":"connected"}\n\n');
1971
- const interval = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1972
- req.on('close', () => clearInterval(interval));
1965
+ server.initSse(req.params.id, res);
1973
1966
  });
1974
1967
 
1975
1968
  router.get('/api/v1/checkout/:id/events', async (req, res) => {
@@ -1983,15 +1976,7 @@ export function registerCheckoutRoutes(router, ctx) {
1983
1976
  res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1984
1977
  return;
1985
1978
  }
1986
- res.writeHead(200, {
1987
- 'Content-Type': 'text/event-stream',
1988
- 'Cache-Control': 'no-cache',
1989
- 'Connection': 'keep-alive',
1990
- 'Access-Control-Allow-Origin': '*'
1991
- });
1992
- res.write('event: connected\ndata: {"status":"connected"}\n\n');
1993
- const interval = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1994
- req.on('close', () => clearInterval(interval));
1979
+ server.initSse(req.params.id, res);
1995
1980
  });
1996
1981
  }
1997
1982
 
package/src/sse.js ADDED
@@ -0,0 +1,44 @@
1
+ const channels = new Map();
2
+
3
+ export const server = {
4
+ initSse(topic, res) {
5
+ const headers = {
6
+ 'Content-Type': 'text/event-stream',
7
+ 'Cache-Control': 'no-cache',
8
+ 'Connection': 'keep-alive',
9
+ 'Access-Control-Allow-Origin': '*'
10
+ };
11
+ res.writeHead(200, headers);
12
+ res.write('event: connected\ndata: {"status":"connected"}\n\n');
13
+
14
+ if (!channels.has(topic)) channels.set(topic, new Set());
15
+ const set = channels.get(topic);
16
+ set.add(res);
17
+
18
+ const ping = setInterval(() => {
19
+ try { res.write(': keepalive\n\n'); } catch { clearInterval(ping); }
20
+ }, 15000);
21
+ if (ping.unref) ping.unref();
22
+
23
+ res.on('close', () => {
24
+ clearInterval(ping);
25
+ set.delete(res);
26
+ if (set.size === 0) channels.delete(topic);
27
+ });
28
+ },
29
+
30
+ broadcastSse(topic, event, data) {
31
+ const set = channels.get(topic);
32
+ if (!set || set.size === 0) return;
33
+ let safeJson;
34
+ try { safeJson = JSON.stringify(data, (k, v) => typeof v === 'bigint' ? v.toString() : v); } catch { safeJson = '{}'; }
35
+ const named = `event: ${event}\ndata: ${safeJson}\n\n`;
36
+ const def = `data: ${JSON.stringify({ type: event, ...data, event })}\n\n`;
37
+ const payload = named + def;
38
+ for (const r of set) {
39
+ try { r.write(payload); } catch { set.delete(r); }
40
+ }
41
+ }
42
+ };
43
+
44
+ export default server;