@bobfrankston/mailx 1.0.134 → 1.0.136
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": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.136",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.52",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.3",
|
|
@@ -34,6 +34,9 @@ export declare class ImapManager extends EventEmitter {
|
|
|
34
34
|
useNativeClient: boolean;
|
|
35
35
|
/** Accounts hitting connection limits — back off until this time */
|
|
36
36
|
private connectionBackoff;
|
|
37
|
+
/** Per-account connection semaphore — limits concurrent IMAP connections */
|
|
38
|
+
private connectionSemaphore;
|
|
39
|
+
private static MAX_CONNECTIONS;
|
|
37
40
|
constructor(db: MailxDB);
|
|
38
41
|
/** Get OAuth access token for an account (for SMTP auth) */
|
|
39
42
|
getOAuthToken(accountId: string): Promise<string | null>;
|
|
@@ -51,9 +54,16 @@ export declare class ImapManager extends EventEmitter {
|
|
|
51
54
|
createPublicClient(accountId: string): any;
|
|
52
55
|
/** Track active IMAP connections for diagnostics */
|
|
53
56
|
private activeConnections;
|
|
57
|
+
/** Acquire a connection slot. Resolves when a slot is available. */
|
|
58
|
+
private acquireConnection;
|
|
59
|
+
/** Release a connection slot, unblocking the next waiter. */
|
|
60
|
+
private releaseConnection;
|
|
61
|
+
/** Create client with semaphore — acquires slot, wraps logout to release it. */
|
|
62
|
+
createClientWithLimit(accountId: string): Promise<any>;
|
|
54
63
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
55
64
|
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
56
|
-
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
65
|
+
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
66
|
+
* Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
|
|
57
67
|
private createClient;
|
|
58
68
|
/** Track client logout for connection counting (called automatically by wrapped logout) */
|
|
59
69
|
private trackLogout;
|
|
@@ -71,6 +71,26 @@ async function extractPreview(source) {
|
|
|
71
71
|
return { bodyHtml: "", bodyText: "", preview: "", hasAttachments: false };
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
/** Race a promise against a timeout. On timeout, forcibly logout the client to prevent hanging. */
|
|
75
|
+
async function withTimeout(promise, ms, client, label) {
|
|
76
|
+
let timer;
|
|
77
|
+
const timeout = new Promise((_, reject) => {
|
|
78
|
+
timer = setTimeout(() => {
|
|
79
|
+
// Force-close the client to unblock the hanging promise
|
|
80
|
+
try {
|
|
81
|
+
client.logout?.();
|
|
82
|
+
}
|
|
83
|
+
catch { /* ignore */ }
|
|
84
|
+
reject(new Error(`${label} timeout (${ms / 1000}s)`));
|
|
85
|
+
}, ms);
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
return await Promise.race([promise, timeout]);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
74
94
|
export class ImapManager extends EventEmitter {
|
|
75
95
|
configs = new Map();
|
|
76
96
|
watchers = new Map();
|
|
@@ -86,6 +106,9 @@ export class ImapManager extends EventEmitter {
|
|
|
86
106
|
useNativeClient = false;
|
|
87
107
|
/** Accounts hitting connection limits — back off until this time */
|
|
88
108
|
connectionBackoff = new Map();
|
|
109
|
+
/** Per-account connection semaphore — limits concurrent IMAP connections */
|
|
110
|
+
connectionSemaphore = new Map();
|
|
111
|
+
static MAX_CONNECTIONS = 2; // 1 for sync/fetch, 1 for IDLE
|
|
89
112
|
constructor(db) {
|
|
90
113
|
super();
|
|
91
114
|
this.db = db;
|
|
@@ -186,9 +209,68 @@ export class ImapManager extends EventEmitter {
|
|
|
186
209
|
// private legacyFallbacks = new Set<string>();
|
|
187
210
|
/** Track active IMAP connections for diagnostics */
|
|
188
211
|
activeConnections = new Map(); // accountId → count
|
|
212
|
+
/** Acquire a connection slot. Resolves when a slot is available. */
|
|
213
|
+
acquireConnection(accountId) {
|
|
214
|
+
let sem = this.connectionSemaphore.get(accountId);
|
|
215
|
+
if (!sem) {
|
|
216
|
+
sem = { active: 0, waiting: [] };
|
|
217
|
+
this.connectionSemaphore.set(accountId, sem);
|
|
218
|
+
}
|
|
219
|
+
if (sem.active < ImapManager.MAX_CONNECTIONS) {
|
|
220
|
+
sem.active++;
|
|
221
|
+
return Promise.resolve();
|
|
222
|
+
}
|
|
223
|
+
// At limit — queue and wait
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
sem.waiting.push(() => { sem.active++; resolve(); });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Release a connection slot, unblocking the next waiter. */
|
|
229
|
+
releaseConnection(accountId) {
|
|
230
|
+
const sem = this.connectionSemaphore.get(accountId);
|
|
231
|
+
if (!sem)
|
|
232
|
+
return;
|
|
233
|
+
sem.active = Math.max(0, sem.active - 1);
|
|
234
|
+
if (sem.waiting.length > 0 && sem.active < ImapManager.MAX_CONNECTIONS) {
|
|
235
|
+
const next = sem.waiting.shift();
|
|
236
|
+
next();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Create client with semaphore — acquires slot, wraps logout to release it. */
|
|
240
|
+
async createClientWithLimit(accountId) {
|
|
241
|
+
await this.acquireConnection(accountId);
|
|
242
|
+
try {
|
|
243
|
+
const client = this.createClient(accountId);
|
|
244
|
+
// Wrap logout to also release the semaphore slot
|
|
245
|
+
const originalLogout = client.logout;
|
|
246
|
+
let released = false;
|
|
247
|
+
client.logout = async () => {
|
|
248
|
+
await originalLogout.call(client);
|
|
249
|
+
if (!released) {
|
|
250
|
+
released = true;
|
|
251
|
+
this.releaseConnection(accountId);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
// Safety: release slot if client is never logged out (leak protection)
|
|
255
|
+
const leakRelease = setTimeout(() => {
|
|
256
|
+
if (!released) {
|
|
257
|
+
released = true;
|
|
258
|
+
this.releaseConnection(accountId);
|
|
259
|
+
}
|
|
260
|
+
}, 310000); // slightly after the 5min leak timer in createClient
|
|
261
|
+
if (leakRelease.unref)
|
|
262
|
+
leakRelease.unref();
|
|
263
|
+
return client;
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
this.releaseConnection(accountId);
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
189
270
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
190
271
|
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
191
|
-
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
272
|
+
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
273
|
+
* Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
|
|
192
274
|
createClient(accountId) {
|
|
193
275
|
if (this.reauthenticating.has(accountId))
|
|
194
276
|
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
@@ -201,6 +283,10 @@ export class ImapManager extends EventEmitter {
|
|
|
201
283
|
if (!config)
|
|
202
284
|
throw new Error(`No config for account ${accountId}`);
|
|
203
285
|
const count = (this.activeConnections.get(accountId) || 0) + 1;
|
|
286
|
+
// Hard limit: warn if exceeding max, but still allow (callers should use createClientWithLimit)
|
|
287
|
+
if (count > ImapManager.MAX_CONNECTIONS) {
|
|
288
|
+
console.warn(` [conn] ${accountId}: WARNING exceeding limit (${count} > ${ImapManager.MAX_CONNECTIONS})`);
|
|
289
|
+
}
|
|
204
290
|
this.activeConnections.set(accountId, count);
|
|
205
291
|
const clientType = this.useNativeClient ? "native" : "imapflow";
|
|
206
292
|
console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
|
|
@@ -537,11 +623,8 @@ export class ImapManager extends EventEmitter {
|
|
|
537
623
|
let client = null;
|
|
538
624
|
try {
|
|
539
625
|
const t0 = Date.now();
|
|
540
|
-
client = this.
|
|
541
|
-
const folders = await
|
|
542
|
-
this.syncFolders(accountId, client),
|
|
543
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Folder list timeout (30s)")), 30000))
|
|
544
|
-
]);
|
|
626
|
+
client = await this.createClientWithLimit(accountId);
|
|
627
|
+
const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
|
|
545
628
|
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
546
629
|
// Legacy fallback removed — was doubling connections.
|
|
547
630
|
// If native client has issues, set useNativeClient=false or use --legacy-imap flag.
|
|
@@ -552,11 +635,8 @@ export class ImapManager extends EventEmitter {
|
|
|
552
635
|
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
553
636
|
if (inbox) {
|
|
554
637
|
try {
|
|
555
|
-
client = this.
|
|
556
|
-
await
|
|
557
|
-
this.syncFolder(accountId, inbox.id, client),
|
|
558
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
|
|
559
|
-
]);
|
|
638
|
+
client = await this.createClientWithLimit(accountId);
|
|
639
|
+
await withTimeout(this.syncFolder(accountId, inbox.id, client), 60000, client, "Inbox sync");
|
|
560
640
|
await client.logout();
|
|
561
641
|
client = null;
|
|
562
642
|
}
|
|
@@ -644,7 +724,7 @@ export class ImapManager extends EventEmitter {
|
|
|
644
724
|
// Reuse one IMAP connection per account for all folders (avoid 87+ TLS handshakes)
|
|
645
725
|
let client = null;
|
|
646
726
|
try {
|
|
647
|
-
client = this.
|
|
727
|
+
client = await this.createClientWithLimit(accountId);
|
|
648
728
|
for (const folder of remaining) {
|
|
649
729
|
// Skip Trash subfolders on first sync — they're large and low priority
|
|
650
730
|
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
@@ -656,10 +736,7 @@ export class ImapManager extends EventEmitter {
|
|
|
656
736
|
// Longer timeout for folders we know are large (Trash, first sync)
|
|
657
737
|
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
658
738
|
try {
|
|
659
|
-
await
|
|
660
|
-
this.syncFolder(accountId, folder.id, client),
|
|
661
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s) on ${folder.path}`)), timeout))
|
|
662
|
-
]);
|
|
739
|
+
await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
|
|
663
740
|
}
|
|
664
741
|
catch (e) {
|
|
665
742
|
if (e.responseText?.includes("doesn't exist")) {
|
|
@@ -673,7 +750,7 @@ export class ImapManager extends EventEmitter {
|
|
|
673
750
|
await client.logout();
|
|
674
751
|
}
|
|
675
752
|
catch { /* */ }
|
|
676
|
-
client = this.
|
|
753
|
+
client = await this.createClientWithLimit(accountId);
|
|
677
754
|
}
|
|
678
755
|
}
|
|
679
756
|
}
|
|
@@ -706,7 +783,7 @@ export class ImapManager extends EventEmitter {
|
|
|
706
783
|
// Try up to 2 times with fresh clients
|
|
707
784
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
708
785
|
try {
|
|
709
|
-
client = this.
|
|
786
|
+
client = await this.createClientWithLimit(accountId);
|
|
710
787
|
await this.syncFolder(accountId, inbox.id, client);
|
|
711
788
|
await client.logout();
|
|
712
789
|
client = null;
|
|
@@ -750,13 +827,17 @@ export class ImapManager extends EventEmitter {
|
|
|
750
827
|
return;
|
|
751
828
|
if (this.reauthenticating.has(accountId))
|
|
752
829
|
return;
|
|
830
|
+
// Skip if at connection limit — don't queue, just skip this cycle
|
|
831
|
+
const sem = this.connectionSemaphore.get(accountId);
|
|
832
|
+
if (sem && sem.active >= ImapManager.MAX_CONNECTIONS)
|
|
833
|
+
return;
|
|
753
834
|
this.quickCheckRunning.add(accountId);
|
|
754
835
|
let client = null;
|
|
755
836
|
try {
|
|
756
837
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
757
838
|
if (!inbox)
|
|
758
839
|
return;
|
|
759
|
-
client = this.
|
|
840
|
+
client = await this.createClientWithLimit(accountId);
|
|
760
841
|
const count = await client.getMessagesCount("INBOX");
|
|
761
842
|
await client.logout();
|
|
762
843
|
client = null;
|
|
@@ -764,7 +845,7 @@ export class ImapManager extends EventEmitter {
|
|
|
764
845
|
this.lastInboxCounts.set(accountId, count);
|
|
765
846
|
if (count !== prev) {
|
|
766
847
|
console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
|
|
767
|
-
client = this.
|
|
848
|
+
client = await this.createClientWithLimit(accountId);
|
|
768
849
|
await this.syncFolder(accountId, inbox.id, client);
|
|
769
850
|
await client.logout();
|
|
770
851
|
client = null;
|
|
@@ -839,7 +920,7 @@ export class ImapManager extends EventEmitter {
|
|
|
839
920
|
if (this.watchers.has(accountId))
|
|
840
921
|
continue;
|
|
841
922
|
try {
|
|
842
|
-
const watchClient = this.
|
|
923
|
+
const watchClient = await this.createClientWithLimit(accountId);
|
|
843
924
|
const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
|
|
844
925
|
console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
|
|
845
926
|
// Sync just INBOX for speed — full sync runs on the configured interval
|
|
@@ -877,10 +958,10 @@ export class ImapManager extends EventEmitter {
|
|
|
877
958
|
return next;
|
|
878
959
|
}
|
|
879
960
|
/** Get or create a persistent client for body fetching */
|
|
880
|
-
getFetchClient(accountId) {
|
|
961
|
+
async getFetchClient(accountId) {
|
|
881
962
|
let client = this.fetchClients.get(accountId);
|
|
882
963
|
if (!client) {
|
|
883
|
-
client = this.
|
|
964
|
+
client = await this.createClientWithLimit(accountId);
|
|
884
965
|
this.fetchClients.set(accountId, client);
|
|
885
966
|
}
|
|
886
967
|
return client;
|
|
@@ -904,12 +985,9 @@ export class ImapManager extends EventEmitter {
|
|
|
904
985
|
}
|
|
905
986
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
906
987
|
try {
|
|
907
|
-
const client = this.getFetchClient(accountId);
|
|
988
|
+
const client = await this.getFetchClient(accountId);
|
|
908
989
|
// 30s timeout — prevents hanging on stale connections
|
|
909
|
-
const msg = await
|
|
910
|
-
client.fetchMessageByUid(folder.path, uid, { source: true }),
|
|
911
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Body fetch timeout (30s)")), 30000))
|
|
912
|
-
]);
|
|
990
|
+
const msg = await withTimeout(client.fetchMessageByUid(folder.path, uid, { source: true }), 30000, client, "Body fetch");
|
|
913
991
|
if (!msg?.source)
|
|
914
992
|
return null;
|
|
915
993
|
const raw = Buffer.from(msg.source, "utf-8");
|
|
@@ -1359,7 +1437,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1359
1437
|
return;
|
|
1360
1438
|
try {
|
|
1361
1439
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
1362
|
-
const client = this.
|
|
1440
|
+
const client = await this.createClientWithLimit(accountId);
|
|
1363
1441
|
try {
|
|
1364
1442
|
for (const file of files) {
|
|
1365
1443
|
const filePath = path.join(localQueue, file);
|
|
@@ -1389,7 +1467,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1389
1467
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
1390
1468
|
if (!account)
|
|
1391
1469
|
return;
|
|
1392
|
-
const client = this.
|
|
1470
|
+
const client = await this.createClientWithLimit(accountId);
|
|
1393
1471
|
try {
|
|
1394
1472
|
// Get all UIDs in Outbox
|
|
1395
1473
|
const uids = await client.getUids(outboxFolder.path);
|
|
@@ -234,10 +234,41 @@ async function start() {
|
|
|
234
234
|
// Start HTTP server FIRST so UI is always reachable (even during IMAP startup)
|
|
235
235
|
const externalAccess = process.argv.includes("--external");
|
|
236
236
|
const hostname = externalAccess ? "0.0.0.0" : "127.0.0.1";
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
// Retry listen with backoff — Windows CLOSE_WAIT zombies can hold the port for minutes after a crash
|
|
238
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
239
|
+
server = createServer(app);
|
|
240
|
+
// Prevent CLOSE_WAIT accumulation: short keepAlive timeout + connection close headers
|
|
241
|
+
server.keepAliveTimeout = 5000; // close idle keep-alive connections after 5s
|
|
242
|
+
server.headersTimeout = 10000; // kill connections with no headers after 10s
|
|
243
|
+
// Track connections for clean shutdown (prevents CLOSE_WAIT zombies on Windows)
|
|
244
|
+
server.on("connection", (conn) => {
|
|
245
|
+
openConnections.add(conn);
|
|
246
|
+
conn.on("close", () => openConnections.delete(conn));
|
|
247
|
+
});
|
|
248
|
+
// Suppress EADDRINUSE from bubbling to uncaughtException — we handle it here
|
|
249
|
+
server.on("error", () => { }); // will be replaced by listen handler below
|
|
250
|
+
wss = new WebSocketServer({ server });
|
|
251
|
+
wireWebSocket();
|
|
252
|
+
const listenResult = await new Promise((resolve) => {
|
|
253
|
+
server.removeAllListeners("error");
|
|
254
|
+
server.once("error", (e) => { resolve(e.code || e.message); });
|
|
255
|
+
server.listen({ port: PORT, host: hostname, exclusive: false }, () => {
|
|
256
|
+
server.removeAllListeners("error");
|
|
257
|
+
resolve("ok");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
if (listenResult === "ok")
|
|
261
|
+
break;
|
|
262
|
+
if (listenResult === "EADDRINUSE" && attempt < 29) {
|
|
263
|
+
const wait = Math.min(2000 + attempt * 1000, 10000);
|
|
264
|
+
console.log(` Port ${PORT} in use (CLOSE_WAIT zombies?) — retry ${attempt + 1}/30 in ${wait / 1000}s...`);
|
|
265
|
+
server.close();
|
|
266
|
+
await new Promise(r => setTimeout(r, wait));
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
throw new Error(`Cannot bind port ${PORT}: ${listenResult}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
241
272
|
console.log(`mailx server running on http://${hostname}:${PORT}`);
|
|
242
273
|
// Seed contacts (fast — skips existing)
|
|
243
274
|
const seeded = db.seedContactsFromMessages();
|
|
@@ -287,6 +318,8 @@ async function start() {
|
|
|
287
318
|
imapManager.startOutboxWorker();
|
|
288
319
|
}
|
|
289
320
|
// ── Graceful Shutdown ──
|
|
321
|
+
/** Track all open connections so we can destroy them on shutdown (prevents CLOSE_WAIT zombies) */
|
|
322
|
+
const openConnections = new Set();
|
|
290
323
|
async function shutdown() {
|
|
291
324
|
console.log("\nShutting down...");
|
|
292
325
|
const forceExit = setTimeout(() => { console.log("Force exit"); process.exit(1); }, 3000);
|
|
@@ -296,6 +329,11 @@ async function shutdown() {
|
|
|
296
329
|
}
|
|
297
330
|
catch { /* proceed */ }
|
|
298
331
|
db.close();
|
|
332
|
+
// Destroy all open connections immediately — prevents CLOSE_WAIT zombies on Windows
|
|
333
|
+
for (const conn of openConnections) {
|
|
334
|
+
conn.destroy();
|
|
335
|
+
}
|
|
336
|
+
openConnections.clear();
|
|
299
337
|
server?.close();
|
|
300
338
|
clearTimeout(forceExit);
|
|
301
339
|
process.exit(0);
|
|
@@ -308,11 +346,6 @@ process.on("unhandledRejection", (err) => {
|
|
|
308
346
|
process.on("uncaughtException", (err) => {
|
|
309
347
|
console.error("FATAL uncaught exception:", err.message);
|
|
310
348
|
console.error(err.stack);
|
|
311
|
-
// EADDRINUSE = another instance holds the port — exit so node --watch can retry
|
|
312
|
-
if (err.code === "EADDRINUSE") {
|
|
313
|
-
console.error("Port in use — exiting so node --watch can retry");
|
|
314
|
-
process.exit(1);
|
|
315
|
-
}
|
|
316
349
|
// Other exceptions: stay alive, let node --watch handle file-change restarts
|
|
317
350
|
});
|
|
318
351
|
process.on("exit", (code) => {
|