elpaypg 0.0.0-stage → 2.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.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2421 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,369 @@
1
+ import { getObserverToken } from './../vendor/common/index.js';
2
+
3
+ /**
4
+ * ObserverPollerService
5
+ *
6
+ * Consolidated, coordinated background poller and verification daemon.
7
+ * Replaces duplicate, uncoordinated polling loops with a single unified orchestrator:
8
+ * - Automatically idles when no active/processing UPI sessions exist.
9
+ * - Enforces minimum 1.5s–2.0s cooldown between external observer sweeps (SMS and Gmail).
10
+ * - Drops redundant in-flight HTTP requests to prevent observer socket exhaustion.
11
+ * - Serializes observation attribution with a mutex lock to eliminate SQLite lock contention (SQLITE_BUSY).
12
+ * - Reacts immediately to payment events (SESSION.PROCESSING, SESSION.ACTIVATED) without waiting for timer ticks.
13
+ */
14
+ export class ObserverPollerService {
15
+ /**
16
+ * @param {{
17
+ * db: import('../vendor/db/index.js').Database,
18
+ * sessionRepo: import('../vendor/db/index.js').PaymentSessionRepository,
19
+ * obsRepo: import('../vendor/db/index.js').ObservationRepository,
20
+ * attributionEngine: import('../vendor/core/index.js').AttributionEngine,
21
+ * eventEmitter: import('node:events').EventEmitter,
22
+ * pollIntervalMs?: number,
23
+ * minSweepCooldownMs?: number,
24
+ * smsPort?: number,
25
+ * gmailPort?: number,
26
+ * observerToken?: string,
27
+ * autoStart?: boolean
28
+ * }} options
29
+ */
30
+ constructor(options) {
31
+ if (!options || !options.db || !options.sessionRepo || !options.obsRepo || !options.attributionEngine || !options.eventEmitter) {
32
+ throw new Error('ObserverPollerService requires db, sessionRepo, obsRepo, attributionEngine, and eventEmitter');
33
+ }
34
+
35
+ this.db = options.db;
36
+ this.sessionRepo = options.sessionRepo;
37
+ this.obsRepo = options.obsRepo;
38
+ this.attributionEngine = options.attributionEngine;
39
+ this.eventEmitter = options.eventEmitter;
40
+
41
+ // Configurable timing parameters
42
+ this.pollIntervalMs = Number(options.pollIntervalMs || process.env.OBSERVER_POLL_INTERVAL_MS || 1500);
43
+ this.minSweepCooldownMs = Number(options.minSweepCooldownMs || process.env.OBSERVER_SWEEP_COOLDOWN_MS || 2000);
44
+ this.smsPort = Number(options.smsPort || process.env.SMS_OBSERVER_PORT || 6002);
45
+ this.gmailPort = Number(options.gmailPort || process.env.GMAIL_OBSERVER_PORT || 6003);
46
+ this.observerToken = options.observerToken || null;
47
+
48
+ // Lifecycle and concurrency control state
49
+ this._isRunning = false;
50
+ this._isPollingCycle = false;
51
+ this._isIdle = true;
52
+ this._timer = null;
53
+
54
+ // Cooldown and in-flight tracking for observer endpoints
55
+ this._lastSmsSweepTime = 0;
56
+ this._lastGmailSweepTime = 0;
57
+ this._isSmsSweepInFlight = false;
58
+ this._isGmailSweepInFlight = false;
59
+
60
+ // Attribution mutex & in-flight hash set to prevent SQLite write contention
61
+ this._isAttributionInProgress = false;
62
+ this._inFlightObservationHashes = new Set();
63
+
64
+ // Event listener bindings
65
+ this._boundOnSessionProcessing = this.handleSessionProcessing.bind(this);
66
+ this._boundOnSessionActivated = this.handleSessionActivated.bind(this);
67
+ this._boundOnPaymentTerminal = this.handlePaymentTerminal.bind(this);
68
+
69
+ if (options.autoStart !== false) {
70
+ this.start();
71
+ }
72
+ }
73
+
74
+ get isRunning() {
75
+ return this._isRunning;
76
+ }
77
+
78
+ get isIdle() {
79
+ return this._isIdle;
80
+ }
81
+
82
+ get isSmsSweepInFlight() {
83
+ return this._isSmsSweepInFlight;
84
+ }
85
+
86
+ get isGmailSweepInFlight() {
87
+ return this._isGmailSweepInFlight;
88
+ }
89
+
90
+ get isAttributionInProgress() {
91
+ return this._isAttributionInProgress;
92
+ }
93
+
94
+ get lastSmsSweepTime() {
95
+ return this._lastSmsSweepTime;
96
+ }
97
+
98
+ get lastGmailSweepTime() {
99
+ return this._lastGmailSweepTime;
100
+ }
101
+
102
+ /**
103
+ * Starts the consolidated poller daemon and binds event listeners.
104
+ */
105
+ start() {
106
+ if (this._isRunning) return;
107
+ this._isRunning = true;
108
+
109
+ // Bind reactive lifecycle events
110
+ this.eventEmitter.on('SESSION.PROCESSING', this._boundOnSessionProcessing);
111
+ this.eventEmitter.on('SESSION.ACTIVATED', this._boundOnSessionActivated);
112
+ this.eventEmitter.on('PAYMENT.SUCCESS', this._boundOnPaymentTerminal);
113
+ this.eventEmitter.on('PAYMENT.FAILED', this._boundOnPaymentTerminal);
114
+ this.eventEmitter.on('PAYMENT.EXPIRED', this._boundOnPaymentTerminal);
115
+ this.eventEmitter.on('PAYMENT.CANCELLED', this._boundOnPaymentTerminal);
116
+
117
+ // Start single consolidated daemon interval
118
+ this._timer = setInterval(() => {
119
+ this.pollCycle().catch(() => {});
120
+ }, this.pollIntervalMs);
121
+
122
+ if (this._timer.unref) {
123
+ this._timer.unref();
124
+ }
125
+
126
+ // Initial check
127
+ this.pollCycle().catch(() => {});
128
+ }
129
+
130
+ /**
131
+ * Stops the daemon, cleans up timers, and removes event listeners.
132
+ */
133
+ stop() {
134
+ this._isRunning = false;
135
+
136
+ if (this._timer) {
137
+ clearInterval(this._timer);
138
+ this._timer = null;
139
+ }
140
+
141
+ if (this.eventEmitter) {
142
+ this.eventEmitter.removeListener('SESSION.PROCESSING', this._boundOnSessionProcessing);
143
+ this.eventEmitter.removeListener('SESSION.ACTIVATED', this._boundOnSessionActivated);
144
+ this.eventEmitter.removeListener('PAYMENT.SUCCESS', this._boundOnPaymentTerminal);
145
+ this.eventEmitter.removeListener('PAYMENT.FAILED', this._boundOnPaymentTerminal);
146
+ this.eventEmitter.removeListener('PAYMENT.EXPIRED', this._boundOnPaymentTerminal);
147
+ this.eventEmitter.removeListener('PAYMENT.CANCELLED', this._boundOnPaymentTerminal);
148
+ }
149
+
150
+ this._inFlightObservationHashes.clear();
151
+ this._isPollingCycle = false;
152
+ this._isAttributionInProgress = false;
153
+ this._isSmsSweepInFlight = false;
154
+ this._isGmailSweepInFlight = false;
155
+ this._isIdle = true;
156
+ }
157
+
158
+ /**
159
+ * Alias for stop() for standard engine lifecycle parity.
160
+ */
161
+ destroy() {
162
+ this.stop();
163
+ }
164
+
165
+ /**
166
+ * Reactive handler when a customer initiates payment confirmation.
167
+ * Wakes up the poller immediately without waiting for the next interval tick.
168
+ * @param {{ sessionId?: string, receivingAccountId?: string }} data
169
+ */
170
+ handleSessionProcessing(data) {
171
+ this.triggerExpeditedSweep(data?.receivingAccountId);
172
+ }
173
+
174
+ /**
175
+ * Reactive handler when a new session transitions to ACTIVE.
176
+ * @param {{ sessionId?: string, receivingAccountId?: string }} data
177
+ */
178
+ handleSessionActivated(data) {
179
+ this._isIdle = false;
180
+ this.triggerExpeditedSweep(data?.receivingAccountId);
181
+ }
182
+
183
+ /**
184
+ * Reactive handler on terminal payment outcomes.
185
+ */
186
+ handlePaymentTerminal() {
187
+ // Next poll cycle will automatically update idle state
188
+ }
189
+
190
+ /**
191
+ * Immediately triggers a poll sweep, waking the poller if idle.
192
+ * @param {string} [preferredAccountId]
193
+ */
194
+ triggerExpeditedSweep(preferredAccountId = null) {
195
+ if (!this._isRunning || this._isPollingCycle) return;
196
+ this.pollCycle(preferredAccountId).catch(() => {});
197
+ }
198
+
199
+ /**
200
+ * Finds all active payment sessions waiting for bank verification.
201
+ * Includes ACTIVE, PROCESSING, and recent SUCCESS sessions awaiting secondary channel dual/triple match.
202
+ * @returns {Array<{ id: string, receiving_account_id: string, amount_paise: number, status: string, verification_level?: number }>}
203
+ */
204
+ getActiveSessions() {
205
+ try {
206
+ const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
207
+ // A session that already reached the Triple Verification Standard (level 3) is fully
208
+ // confirmed: sweeping it again can never change its outcome, so it is excluded. This
209
+ // preserves the upper bound the previous per-session sweep timers enforced.
210
+ return this.db.query(`
211
+ SELECT id, receiving_account_id, amount_paise, status, verification_level
212
+ FROM payment_sessions
213
+ WHERE method = 'UPI' AND (
214
+ (status IN ('ACTIVE', 'PROCESSING') AND COALESCE(verification_level, 0) < 3)
215
+ OR (status = 'SUCCESS' AND COALESCE(verification_level, 0) < 2 AND completed_at >= ?)
216
+ )
217
+ `, [sixtySecsAgo]);
218
+ } catch {
219
+ return [];
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Executes a single coordinated polling cycle:
225
+ * 1. Evaluates active sessions; idles immediately if none are active.
226
+ * 2. Fires throttled sweeps to SMS and Gmail observers (respecting cooldown & in-flight lock).
227
+ * 3. Sequentially processes unattributed bank observations under mutex lock.
228
+ * @param {string} [preferredAccountId]
229
+ */
230
+ async pollCycle(preferredAccountId = null) {
231
+ if (!this._isRunning || this._isPollingCycle) return;
232
+ this._isPollingCycle = true;
233
+
234
+ try {
235
+ const activeSessions = this.getActiveSessions();
236
+
237
+ if (activeSessions.length === 0) {
238
+ this._isIdle = true;
239
+ return;
240
+ }
241
+
242
+ this._isIdle = false;
243
+
244
+ // 1. Concurrently trigger external observer sweeps with strict cooldown & in-flight locks
245
+ await Promise.allSettled([
246
+ this.sweepSmsObserver(),
247
+ this.sweepGmailObserver()
248
+ ]);
249
+
250
+ // 2. Process pending unattributed bank observations in DB under sequential mutex lock
251
+ await this.processPendingAttributions(activeSessions, preferredAccountId);
252
+ } finally {
253
+ this._isPollingCycle = false;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Sweeps Google Messages SMS Observer with cooldown and in-flight protection.
259
+ * Drops redundant calls if cooldown has not elapsed or another sweep is currently in flight.
260
+ * @returns {Promise<boolean>} True if sweep was dispatched, false if dropped/in-flight/cooldown.
261
+ */
262
+ async sweepSmsObserver() {
263
+ const now = Date.now();
264
+ if (this._isSmsSweepInFlight || (now - this._lastSmsSweepTime < this.minSweepCooldownMs)) {
265
+ return false;
266
+ }
267
+
268
+ this._isSmsSweepInFlight = true;
269
+ this._lastSmsSweepTime = now;
270
+
271
+ try {
272
+ const token = this.observerToken || getObserverToken();
273
+ const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
274
+
275
+ await fetch(`http://localhost:${this.smsPort}/api/sweep`, {
276
+ method: 'POST',
277
+ headers,
278
+ signal: AbortSignal.timeout(1200)
279
+ });
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ } finally {
284
+ this._isSmsSweepInFlight = false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Sweeps Gmail Observer with cooldown and in-flight protection.
290
+ * Drops redundant calls if cooldown has not elapsed or another sweep is currently in flight.
291
+ * @returns {Promise<boolean>} True if sweep was dispatched, false if dropped/in-flight/cooldown.
292
+ */
293
+ async sweepGmailObserver() {
294
+ const now = Date.now();
295
+ if (this._isGmailSweepInFlight || (now - this._lastGmailSweepTime < this.minSweepCooldownMs)) {
296
+ return false;
297
+ }
298
+
299
+ this._isGmailSweepInFlight = true;
300
+ this._lastGmailSweepTime = now;
301
+
302
+ try {
303
+ const token = this.observerToken || getObserverToken();
304
+ const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
305
+
306
+ await fetch(`http://localhost:${this.gmailPort}/api/sweep`, {
307
+ method: 'POST',
308
+ headers,
309
+ signal: AbortSignal.timeout(1200)
310
+ });
311
+ return true;
312
+ } catch {
313
+ return false;
314
+ } finally {
315
+ this._isGmailSweepInFlight = false;
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Processes unattributed observations for active accounts under strict sequential mutex lock.
321
+ * Deduplicates receivingAccountIds and tracks in-flight observation hashes to prevent
322
+ * concurrent SQLite write transactions and SQLITE_BUSY contention.
323
+ * @param {Array<{ receiving_account_id: string }>} activeSessions
324
+ * @param {string} [preferredAccountId]
325
+ */
326
+ async processPendingAttributions(activeSessions, preferredAccountId = null) {
327
+ if (this._isAttributionInProgress) return;
328
+ this._isAttributionInProgress = true;
329
+
330
+ try {
331
+ const rawAccountIds = activeSessions.map(s => s.receiving_account_id).filter(Boolean);
332
+ const accountIds = [...new Set(rawAccountIds)];
333
+
334
+ if (preferredAccountId && accountIds.includes(preferredAccountId)) {
335
+ accountIds.sort((a, b) => a === preferredAccountId ? -1 : (b === preferredAccountId ? 1 : 0));
336
+ }
337
+
338
+ for (const accountId of accountIds) {
339
+ const unattributed = this.obsRepo.findUnattributed(accountId);
340
+ if (!Array.isArray(unattributed) || unattributed.length === 0) continue;
341
+
342
+ for (const obs of unattributed) {
343
+ const hash = obs.fingerprintHash || obs.id;
344
+ if (this._inFlightObservationHashes.has(hash)) continue;
345
+
346
+ this._inFlightObservationHashes.add(hash);
347
+ try {
348
+ this.attributionEngine.processObservation({
349
+ source: obs.source,
350
+ receivingAccountId: obs.receivingAccountId,
351
+ amountPaise: obs.amountPaise,
352
+ observedAt: obs.observedAt,
353
+ bankReferenceNumber: obs.bankReferenceNumber,
354
+ senderVpa: obs.senderVpa,
355
+ rawPayload: obs.rawPayload,
356
+ fingerprintHash: obs.fingerprintHash
357
+ });
358
+ } catch {
359
+ // Silently absorb individual observation attribution failures to avoid interrupting poller loop
360
+ } finally {
361
+ this._inFlightObservationHashes.delete(hash);
362
+ }
363
+ }
364
+ }
365
+ } finally {
366
+ this._isAttributionInProgress = false;
367
+ }
368
+ }
369
+ }
@@ -0,0 +1,130 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { buildCloudflaredInvocation } from './../vendor/common/index.js';
4
+
5
+ /**
6
+ * Cloudflare Tunnel Process Manager
7
+ * Manages running user-provided cloudflared commands in the background with live log streaming.
8
+ */
9
+ export class TunnelManager extends EventEmitter {
10
+ constructor() {
11
+ super();
12
+ this.process = null;
13
+ this.status = 'STOPPED'; // 'STOPPED' | 'STARTING' | 'RUNNING' | 'ERROR'
14
+ this.command = null;
15
+ this.logs = [];
16
+ this.maxLogs = 200;
17
+ this.pid = null;
18
+ this.startedAt = null;
19
+ }
20
+
21
+ appendLog(line) {
22
+ const entry = {
23
+ timestamp: new Date().toISOString(),
24
+ text: line.trim()
25
+ };
26
+ this.logs.push(entry);
27
+ if (this.logs.length > this.maxLogs) {
28
+ this.logs.shift();
29
+ }
30
+ this.emit('log', entry);
31
+ }
32
+
33
+ getStatus() {
34
+ return {
35
+ status: this.status,
36
+ command: this.command, // redacted: never contains the token
37
+ pid: this.pid,
38
+ startedAt: this.startedAt,
39
+ isRunning: this.status === 'RUNNING' || this.status === 'STARTING',
40
+ recentLogs: this.logs.slice(-50)
41
+ };
42
+ }
43
+
44
+ start(rawCommand) {
45
+ if (!rawCommand || !rawCommand.trim()) {
46
+ throw new Error('Cloudflare Tunnel command cannot be empty.');
47
+ }
48
+
49
+ if (this.process && (this.status === 'RUNNING' || this.status === 'STARTING')) {
50
+ this.stop();
51
+ }
52
+
53
+ // Only the extracted token is used; the pasted text is never run through a shell.
54
+ const invocation = buildCloudflaredInvocation(rawCommand);
55
+ this.command = invocation.redacted;
56
+ this.status = 'STARTING';
57
+ this.startedAt = new Date().toISOString();
58
+ this.appendLog(`[TunnelManager] Launching tunnel: ${invocation.redacted}`);
59
+
60
+ try {
61
+ const child = spawn(invocation.file, invocation.args, {
62
+ shell: false,
63
+ detached: false,
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ env: { ...process.env }
66
+ });
67
+
68
+ this.process = child;
69
+ this.pid = child.pid;
70
+ this.status = 'RUNNING';
71
+
72
+ child.stdout.on('data', data => {
73
+ const text = data.toString();
74
+ this.appendLog(`[STDOUT] ${text}`);
75
+ });
76
+
77
+ child.stderr.on('data', data => {
78
+ const text = data.toString();
79
+ this.appendLog(`[STDERR] ${text}`);
80
+ });
81
+
82
+ child.on('error', err => {
83
+ this.status = 'ERROR';
84
+ this.appendLog(`[ERROR] Process failed to spawn: ${err.message}`);
85
+ this.emit('error', err);
86
+ });
87
+
88
+ child.on('close', code => {
89
+ this.appendLog(`[EXIT] Tunnel process exited with code ${code}`);
90
+ this.status = 'STOPPED';
91
+ this.process = null;
92
+ this.pid = null;
93
+ this.emit('exit', code);
94
+ });
95
+
96
+ return this.getStatus();
97
+ } catch (err) {
98
+ this.status = 'ERROR';
99
+ this.appendLog(`[ERROR] Exception spawning tunnel: ${err.message}`);
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ stop() {
105
+ if (!this.process) {
106
+ this.status = 'STOPPED';
107
+ this.pid = null;
108
+ return this.getStatus();
109
+ }
110
+
111
+ this.appendLog(`[TunnelManager] Stopping tunnel process (PID: ${this.pid})...`);
112
+ try {
113
+ if (process.platform === 'win32' && this.pid) {
114
+ spawn('taskkill', ['/pid', String(this.pid), '/f', '/t']);
115
+ } else if (this.pid) {
116
+ process.kill(this.pid, 'SIGTERM');
117
+ }
118
+ } catch (e) {
119
+ this.appendLog(`[TunnelManager] Kill error notice: ${e.message}`);
120
+ }
121
+
122
+ this.process = null;
123
+ this.status = 'STOPPED';
124
+ this.pid = null;
125
+ return this.getStatus();
126
+ }
127
+ }
128
+
129
+ // Global singleton instance
130
+ export const globalTunnelManager = new TunnelManager();