@xeplr/utils 1.0.0 → 1.0.1
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/index.js +8 -2
- package/isomorphic/crypto.js +60 -1
- package/lib/cache.js +22 -2
- package/lib/email.js +50 -4
- package/lib/fileUploader.js +17 -1
- package/lib/otp.js +111 -0
- package/lib/queue.js +322 -0
- package/package.json +46 -13
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { sendEmail, configureEmail } = require('./lib/email');
|
|
1
|
+
const { sendEmail, configureEmail, emailConfigFromEnv, configureFromEnv } = require('./lib/email');
|
|
2
2
|
const { configureLogger, createSession, log, closeSession, getSessionLogs } = require('./lib/logger');
|
|
3
3
|
const { generateId, formatDbDateTime, mysqlDateTime } = require('./lib/helpers');
|
|
4
4
|
const { respond, sanitizeError } = require('./lib/response');
|
|
@@ -7,11 +7,14 @@ const Queue = require('./lib/queue');
|
|
|
7
7
|
const FileUploader = require('./lib/fileUploader');
|
|
8
8
|
const RateLimiter = require('./lib/rateLimiter');
|
|
9
9
|
const sms = require('./lib/sms');
|
|
10
|
+
const otp = require('./lib/otp');
|
|
10
11
|
|
|
11
12
|
module.exports = {
|
|
12
13
|
// Email
|
|
13
14
|
sendEmail,
|
|
14
15
|
configureEmail,
|
|
16
|
+
emailConfigFromEnv,
|
|
17
|
+
configureFromEnv,
|
|
15
18
|
|
|
16
19
|
// Logger client
|
|
17
20
|
configureLogger,
|
|
@@ -42,5 +45,8 @@ module.exports = {
|
|
|
42
45
|
RateLimiter,
|
|
43
46
|
|
|
44
47
|
// SMS
|
|
45
|
-
sms
|
|
48
|
+
sms,
|
|
49
|
+
|
|
50
|
+
// OTP (uses sms above for delivery)
|
|
51
|
+
otp
|
|
46
52
|
};
|
package/isomorphic/crypto.js
CHANGED
|
@@ -120,4 +120,63 @@ async function decrypt(encrypted, key) {
|
|
|
120
120
|
return new TextDecoder().decode(decrypted);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Same as encrypt(), but returns the salt separately instead of packing it
|
|
125
|
+
* into the payload — for callers that store salt in its own column/field
|
|
126
|
+
* (e.g. to keep the ciphertext opaque even to something inspecting schema,
|
|
127
|
+
* or to swap in a different encryption backend later that takes salt as an
|
|
128
|
+
* explicit input rather than embedding it).
|
|
129
|
+
*
|
|
130
|
+
* @param {string} plaintext
|
|
131
|
+
* @param {string} key - Passphrase
|
|
132
|
+
* @param {string} [salt] - Base64 salt to reuse (e.g. rotating the ciphertext
|
|
133
|
+
* without rotating the salt). Omit to generate a new one.
|
|
134
|
+
* @returns {Promise<{salt: string, ciphertext: string}>} both base64
|
|
135
|
+
*/
|
|
136
|
+
async function encryptSplit(plaintext, key, salt) {
|
|
137
|
+
var saltBytes = salt ? fromBase64(salt) : new Uint8Array(16);
|
|
138
|
+
if (!salt) getRandomValues(saltBytes);
|
|
139
|
+
var iv = new Uint8Array(12);
|
|
140
|
+
getRandomValues(iv);
|
|
141
|
+
|
|
142
|
+
var cryptoKey = await deriveKey(key, saltBytes);
|
|
143
|
+
var enc = new TextEncoder();
|
|
144
|
+
var ciphertext = await getSubtle().encrypt(
|
|
145
|
+
{ name: 'AES-GCM', iv: iv },
|
|
146
|
+
cryptoKey,
|
|
147
|
+
enc.encode(plaintext)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Pack: iv (12) + ciphertext — salt travels separately.
|
|
151
|
+
var packed = new Uint8Array(12 + ciphertext.byteLength);
|
|
152
|
+
packed.set(iv, 0);
|
|
153
|
+
packed.set(new Uint8Array(ciphertext), 12);
|
|
154
|
+
|
|
155
|
+
return { salt: toBase64(saltBytes), ciphertext: toBase64(packed) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Counterpart to encryptSplit() — salt passed in explicitly rather than read
|
|
160
|
+
* from the payload.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} ciphertext - Base64 payload from encryptSplit()
|
|
163
|
+
* @param {string} key - Passphrase
|
|
164
|
+
* @param {string} salt - Base64 salt from encryptSplit()
|
|
165
|
+
* @returns {Promise<string>} Decrypted plaintext
|
|
166
|
+
*/
|
|
167
|
+
async function decryptSplit(ciphertext, key, salt) {
|
|
168
|
+
var packed = fromBase64(ciphertext);
|
|
169
|
+
var iv = packed.slice(0, 12);
|
|
170
|
+
var data = packed.slice(12);
|
|
171
|
+
|
|
172
|
+
var cryptoKey = await deriveKey(key, fromBase64(salt));
|
|
173
|
+
var decrypted = await getSubtle().decrypt(
|
|
174
|
+
{ name: 'AES-GCM', iv: iv },
|
|
175
|
+
cryptoKey,
|
|
176
|
+
data
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return new TextDecoder().decode(decrypted);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { encrypt, decrypt, encryptSplit, decryptSplit };
|
package/lib/cache.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
// `ioredis` is an OPTIONAL peer dependency — nothing in xeplr-utils
|
|
2
|
+
// requires Redis unless you actually use the cache. We lazy-require it so
|
|
3
|
+
// projects that never touch cache/queue/sessions never have to install it.
|
|
4
|
+
// If they DO reach for cache without installing ioredis, throw a message
|
|
5
|
+
// that names the fix, not a raw "Cannot find module" from deep in node.
|
|
6
|
+
let Redis = null;
|
|
7
|
+
function loadRedis() {
|
|
8
|
+
if (Redis) return Redis;
|
|
9
|
+
try { Redis = require('ioredis'); return Redis; }
|
|
10
|
+
catch (e) {
|
|
11
|
+
var err = new Error(
|
|
12
|
+
"@xeplr/utils/lib/cache: 'ioredis' is not installed. Run `npm install ioredis` " +
|
|
13
|
+
"in your project. It's an optional peer dependency — only required if you use " +
|
|
14
|
+
"the cache, queue, or any session-backed feature (xeplr-auth sessions, SSE " +
|
|
15
|
+
"tickets, email queue)."
|
|
16
|
+
);
|
|
17
|
+
err.cause = e;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
2
21
|
|
|
3
22
|
let _client = null;
|
|
4
23
|
let _config = {};
|
|
@@ -38,6 +57,7 @@ function configureCache(config = {}) {
|
|
|
38
57
|
|
|
39
58
|
function getClient() {
|
|
40
59
|
if (_client) return _client;
|
|
60
|
+
const RedisCtor = loadRedis();
|
|
41
61
|
|
|
42
62
|
const opts = {
|
|
43
63
|
host: _config.host || process.env.REDIS_HOST || '127.0.0.1',
|
|
@@ -64,7 +84,7 @@ function getClient() {
|
|
|
64
84
|
// For extreme throughput, use Cluster mode instead.
|
|
65
85
|
};
|
|
66
86
|
|
|
67
|
-
_client = new
|
|
87
|
+
_client = new RedisCtor(opts);
|
|
68
88
|
|
|
69
89
|
_client.on('error', (err) => {
|
|
70
90
|
// Silently handle — don't crash the server if Redis is down
|
package/lib/email.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
// `nodemailer` is an optional peer dep — lazy-loaded so projects that
|
|
2
|
+
// don't send email don't have to install it (or its transitive deps).
|
|
3
|
+
let _nodemailer = null;
|
|
4
|
+
function loadNodemailer() {
|
|
5
|
+
if (_nodemailer) return _nodemailer;
|
|
6
|
+
try { _nodemailer = require('nodemailer'); return _nodemailer; }
|
|
7
|
+
catch (e) {
|
|
8
|
+
var err = new Error(
|
|
9
|
+
"@xeplr/utils/lib/email: 'nodemailer' is not installed. Run `npm install nodemailer` " +
|
|
10
|
+
"in your project. It's an optional peer dependency — only required if you send email."
|
|
11
|
+
);
|
|
12
|
+
err.cause = e;
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
2
17
|
const fs = require('fs');
|
|
3
18
|
const path = require('path');
|
|
4
19
|
|
|
@@ -102,7 +117,7 @@ function buildAttachments(attachments) {
|
|
|
102
117
|
|
|
103
118
|
async function sendViaSMTP(to, subject, html, cc, attachments) {
|
|
104
119
|
var cfg = getConfig().smtp;
|
|
105
|
-
var transporter =
|
|
120
|
+
var transporter = loadNodemailer().createTransport({
|
|
106
121
|
host: cfg.host,
|
|
107
122
|
port: cfg.port,
|
|
108
123
|
secure: false,
|
|
@@ -122,7 +137,7 @@ async function sendViaAWS(to, subject, html, cc, attachments) {
|
|
|
122
137
|
// If there are attachments or cc, use SESv2 with raw email via nodemailer
|
|
123
138
|
if ((attachments && attachments.length) || (cc && cc.length)) {
|
|
124
139
|
var { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
|
|
125
|
-
var transporter =
|
|
140
|
+
var transporter = loadNodemailer().createTransport({ streamTransport: true });
|
|
126
141
|
|
|
127
142
|
var mailOptions = { from: cfg.from, to, subject, html };
|
|
128
143
|
if (cc && cc.length) mailOptions.cc = cc;
|
|
@@ -300,4 +315,35 @@ async function replayDeadLetters() {
|
|
|
300
315
|
return items.length;
|
|
301
316
|
}
|
|
302
317
|
|
|
303
|
-
|
|
318
|
+
// Build an email config from env (EMAIL_PROVIDER + provider vars). Pure — returns
|
|
319
|
+
// the config or null if no provider is set. The single source for env→email config.
|
|
320
|
+
function emailConfigFromEnv() {
|
|
321
|
+
var provider = process.env.EMAIL_PROVIDER;
|
|
322
|
+
if (!provider) return null;
|
|
323
|
+
|
|
324
|
+
var config = { provider: provider };
|
|
325
|
+
if (provider === 'brevo') {
|
|
326
|
+
config.brevo = {
|
|
327
|
+
apiKey: process.env.BREVO_API_KEY,
|
|
328
|
+
fromEmail: process.env.BREVO_FROM_EMAIL,
|
|
329
|
+
fromName: process.env.BREVO_FROM_NAME
|
|
330
|
+
};
|
|
331
|
+
} else if (provider === 'smtp') {
|
|
332
|
+
config.smtp = {
|
|
333
|
+
host: process.env.SMTP_HOST, port: process.env.SMTP_PORT,
|
|
334
|
+
user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
// aws / azure read their own vars here when those providers are used.
|
|
338
|
+
return config;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Configure the email sender straight from env. Returns true if a provider was set.
|
|
342
|
+
function configureFromEnv() {
|
|
343
|
+
var config = emailConfigFromEnv();
|
|
344
|
+
if (!config) return false;
|
|
345
|
+
configureEmail(config);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = { sendEmail, configureEmail, emailConfigFromEnv, configureFromEnv, getDeadLetterQueue, replayDeadLetters };
|
package/lib/fileUploader.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
// `multer` is an optional peer dep — lazy-loaded so projects that
|
|
2
|
+
// don't handle multipart uploads don't have to install it.
|
|
3
|
+
let _multer = null;
|
|
4
|
+
function loadMulter() {
|
|
5
|
+
if (_multer) return _multer;
|
|
6
|
+
try { _multer = require('multer'); return _multer; }
|
|
7
|
+
catch (e) {
|
|
8
|
+
var err = new Error(
|
|
9
|
+
"@xeplr/utils/lib/fileUploader: 'multer' is not installed. Run `npm install multer` " +
|
|
10
|
+
"in your project. It's an optional peer dependency — only required if you handle file uploads."
|
|
11
|
+
);
|
|
12
|
+
err.cause = e;
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
2
17
|
const path = require('path');
|
|
3
18
|
const fs = require('fs');
|
|
4
19
|
const { generateId } = require('./helpers');
|
|
@@ -21,6 +36,7 @@ class FileUploader {
|
|
|
21
36
|
fs.mkdirSync(this._destination, { recursive: true });
|
|
22
37
|
}
|
|
23
38
|
|
|
39
|
+
var multer = loadMulter();
|
|
24
40
|
this._multer = multer({
|
|
25
41
|
storage: multer.diskStorage({
|
|
26
42
|
destination: (req, file, cb) => {
|
package/lib/otp.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP (one-time password) — generate, deliver, and verify short-lived codes.
|
|
3
|
+
* Delivery goes through THIS package's own sms module (see lib/sms.js: register
|
|
4
|
+
* a provider there, then otp.js sends through it) — no separate provider
|
|
5
|
+
* registration here, one registry to keep in sync.
|
|
6
|
+
*
|
|
7
|
+
* Codes live in Redis (lib/cache.js) — no DB table, no cleanup job. Expiry is
|
|
8
|
+
* just the Redis key's TTL.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* var otp = require('@xeplr/utils').otp;
|
|
12
|
+
* otp.configureOtp({ provider: 'twilio' }); // optional — see defaults below
|
|
13
|
+
*
|
|
14
|
+
* await otp.requestOtp('+91...'); // generates + sends a code
|
|
15
|
+
* await otp.verifyOtp('+91...', '482913'); // { success: true } or { success:false, reason }
|
|
16
|
+
*/
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const cache = require('./cache');
|
|
19
|
+
const sms = require('./sms');
|
|
20
|
+
|
|
21
|
+
var _config = {
|
|
22
|
+
codeLength: 6,
|
|
23
|
+
ttlSeconds: 300, // 5 minutes
|
|
24
|
+
maxAttempts: 5,
|
|
25
|
+
keyPrefix: 'otp:',
|
|
26
|
+
provider: null, // sms provider name (see lib/sms.js register()); falls back to sms.setDefault()
|
|
27
|
+
message: function(code) { return 'Your verification code is ' + code; }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Configure OTP policy. All optional — defaults above apply otherwise.
|
|
32
|
+
* @param {object} config
|
|
33
|
+
* @param {number} [config.codeLength=6]
|
|
34
|
+
* @param {number} [config.ttlSeconds=300]
|
|
35
|
+
* @param {number} [config.maxAttempts=5]
|
|
36
|
+
* @param {string} [config.provider] - sms provider name registered via sms.register()
|
|
37
|
+
* @param {function} [config.message] - (code) => string, the SMS body
|
|
38
|
+
*/
|
|
39
|
+
function configureOtp(config) {
|
|
40
|
+
Object.assign(_config, config || {});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function generateCode() {
|
|
44
|
+
var max = Math.pow(10, _config.codeLength);
|
|
45
|
+
var code = crypto.randomInt(0, max);
|
|
46
|
+
return String(code).padStart(_config.codeLength, '0');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function otpKey(identifier) {
|
|
50
|
+
return _config.keyPrefix + identifier;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate a code, store it, and send it via the configured SMS provider.
|
|
55
|
+
* @param {string} identifier - phone number (or any unique key) the code is tied to
|
|
56
|
+
* @returns {Promise<{ sent: boolean }>}
|
|
57
|
+
*/
|
|
58
|
+
async function requestOtp(identifier) {
|
|
59
|
+
var code = generateCode();
|
|
60
|
+
await cache.set(otpKey(identifier), { code: code, attempts: 0 }, _config.ttlSeconds);
|
|
61
|
+
|
|
62
|
+
var message = _config.message(code);
|
|
63
|
+
var result = _config.provider
|
|
64
|
+
? await sms.send(_config.provider, { to: identifier, message: message })
|
|
65
|
+
: await sms.send({ to: identifier, message: message });
|
|
66
|
+
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
await cache.del(otpKey(identifier));
|
|
69
|
+
throw new Error('Failed to send OTP: ' + (result.error || 'unknown error'));
|
|
70
|
+
}
|
|
71
|
+
return { sent: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Verify a submitted code against the stored one. Single-use — deleted on
|
|
76
|
+
* success. maxAttempts is the real security bound (not TTL precision): a
|
|
77
|
+
* wrong guess re-arms the same TTL window rather than tracking remaining
|
|
78
|
+
* time, so repeated wrong guesses can extend the window, but never the
|
|
79
|
+
* attempt count past maxAttempts.
|
|
80
|
+
* @param {string} identifier
|
|
81
|
+
* @param {string} code
|
|
82
|
+
* @returns {Promise<{ success: boolean, reason?: 'expired'|'invalid'|'too_many_attempts' }>}
|
|
83
|
+
*/
|
|
84
|
+
async function verifyOtp(identifier, code) {
|
|
85
|
+
var key = otpKey(identifier);
|
|
86
|
+
var record = await cache.get(key);
|
|
87
|
+
|
|
88
|
+
if (!record) {
|
|
89
|
+
return { success: false, reason: 'expired' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (record.attempts >= _config.maxAttempts) {
|
|
93
|
+
await cache.del(key);
|
|
94
|
+
return { success: false, reason: 'too_many_attempts' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (String(code) !== record.code) {
|
|
98
|
+
record.attempts += 1;
|
|
99
|
+
if (record.attempts >= _config.maxAttempts) {
|
|
100
|
+
await cache.del(key);
|
|
101
|
+
return { success: false, reason: 'too_many_attempts' };
|
|
102
|
+
}
|
|
103
|
+
await cache.set(key, record, _config.ttlSeconds);
|
|
104
|
+
return { success: false, reason: 'invalid' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await cache.del(key);
|
|
108
|
+
return { success: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { configureOtp, requestOtp, verifyOtp };
|
package/lib/queue.js
CHANGED
|
@@ -157,4 +157,326 @@ class Queue {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// SqlQueue — in-memory concurrent worker pool for SQL execution.
|
|
162
|
+
//
|
|
163
|
+
// Movement-scoped: every item carries a `movementId`. Rollback is by
|
|
164
|
+
// movementId. Aborting drops all queued items for one movement without
|
|
165
|
+
// affecting others.
|
|
166
|
+
//
|
|
167
|
+
// Retry / bisect / drop / abort behavior:
|
|
168
|
+
// - Up to `maxAttempts` per item (default 3); errorExecutor decides
|
|
169
|
+
// what to do on each failure ('retry' | 'bisect' | 'error-table' |
|
|
170
|
+
// 'drop' | 'fatal').
|
|
171
|
+
// - 'bisect' splits `item.meta.rows` in half, regenerates two child
|
|
172
|
+
// SQL statements via `item.meta.rowsToSql(rows)`, and re-enqueues both.
|
|
173
|
+
// - 'error-table' calls `onErrorTable({item, error, rowNum})` and counts
|
|
174
|
+
// as a drop.
|
|
175
|
+
// - `maxConsecutiveDrops` (default 5) drops within the same movement
|
|
176
|
+
// → auto-abort that movement.
|
|
177
|
+
// - 'fatal' → auto-abort that movement immediately.
|
|
178
|
+
//
|
|
179
|
+
// Backpressure: total serialized item bytes ≤ `maxMemoryMB * 1024 * 1024`
|
|
180
|
+
// (default 200MB high water). `addToQueue` returns a promise that awaits
|
|
181
|
+
// when over the ceiling, resumes at 75% low water.
|
|
182
|
+
//
|
|
183
|
+
// Encrypted connections: `connections: { name: encryptedString | plainConfig }`
|
|
184
|
+
// + `encryptionKey`. String values are decrypted via
|
|
185
|
+
// `@xeplr/utils/isomorphic/crypto` on first use and cached.
|
|
186
|
+
class SqlQueue {
|
|
187
|
+
constructor(options) {
|
|
188
|
+
options = options || {};
|
|
189
|
+
if (typeof options.executor !== 'function') {
|
|
190
|
+
throw new Error('SqlQueue: options.executor is required (async (item, resolvedConn) => void)');
|
|
191
|
+
}
|
|
192
|
+
this.executor = options.executor;
|
|
193
|
+
this.errorExecutor = options.errorExecutor || defaultErrorExecutor;
|
|
194
|
+
this.onErrorTable = options.onErrorTable || function() {};
|
|
195
|
+
this.onMovementAbort = options.onMovementAbort || function() {};
|
|
196
|
+
this.concurrency = options.concurrency || 4;
|
|
197
|
+
this.maxAttempts = options.maxAttempts || 3;
|
|
198
|
+
this.maxConsecutiveDrops= options.maxConsecutiveDrops|| 5;
|
|
199
|
+
this.maxMemoryBytes = (options.maxMemoryMB || 200) * 1024 * 1024;
|
|
200
|
+
this.lowMemoryBytes = Math.floor(this.maxMemoryBytes * 0.75);
|
|
201
|
+
this.retryDelaysMs = options.retryDelaysMs || [5000, 15000, 45000];
|
|
202
|
+
this.connections = options.connections || {};
|
|
203
|
+
this.encryptionKey = options.encryptionKey || null;
|
|
204
|
+
|
|
205
|
+
this._items = [];
|
|
206
|
+
this._itemId = 0;
|
|
207
|
+
this._byteTotal = 0;
|
|
208
|
+
this._inFlight = 0;
|
|
209
|
+
this._pendingRetries = 0; // items awaiting their retry setTimeout
|
|
210
|
+
this._stopped = false;
|
|
211
|
+
this._paused = false;
|
|
212
|
+
this._pressureWaiters = [];
|
|
213
|
+
this._idleWaiters = [];
|
|
214
|
+
this._resolved = {}; // decrypted connection cache
|
|
215
|
+
this._movements = new Map(); // movementId → state
|
|
216
|
+
|
|
217
|
+
this._workers = [];
|
|
218
|
+
for (var i = 0; i < this.concurrency; i++) {
|
|
219
|
+
this._workers.push(this._workerLoop());
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Enqueue an item. Awaits when the queue is over its memory ceiling.
|
|
224
|
+
// Item shape:
|
|
225
|
+
// { movementId, connection, sql?, meta: { rows?, rowsToSql?, errorTable?, ... }, attempt? }
|
|
226
|
+
// `sql` is generated from `meta.rowsToSql(meta.rows)` if not supplied.
|
|
227
|
+
async addToQueue(item) {
|
|
228
|
+
if (this._stopped) throw new Error('SqlQueue is stopped');
|
|
229
|
+
if (!item || !item.movementId) throw new Error('addToQueue: item.movementId is required');
|
|
230
|
+
if (!item.sql && item.meta && typeof item.meta.rowsToSql === 'function' && Array.isArray(item.meta.rows)) {
|
|
231
|
+
var builtA = item.meta.rowsToSql(item.meta.rows);
|
|
232
|
+
if (typeof builtA === 'string') { item.sql = builtA; }
|
|
233
|
+
else if (builtA && builtA.sql) { item.sql = builtA.sql; if (builtA.params != null) item.params = builtA.params; }
|
|
234
|
+
}
|
|
235
|
+
if (!item.sql) throw new Error('addToQueue: item.sql is required (or provide meta.rowsToSql + meta.rows)');
|
|
236
|
+
|
|
237
|
+
while (this._byteTotal >= this.maxMemoryBytes) {
|
|
238
|
+
await new Promise(function(res) { this._pressureWaiters.push(res); }.bind(this));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
item.id = ++this._itemId;
|
|
242
|
+
item.attempt = item.attempt || 0;
|
|
243
|
+
item.bytes = approximateBytes(item);
|
|
244
|
+
this._byteTotal += item.bytes;
|
|
245
|
+
this._items.push(item);
|
|
246
|
+
|
|
247
|
+
var state = this._stateFor(item.movementId);
|
|
248
|
+
state.queued++;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Abort a movement. Drops queued items for that movement and prevents
|
|
252
|
+
// in-flight retries. Emits onMovementAbort. In-flight SQLs are allowed
|
|
253
|
+
// to settle — caller runs uploader.rollback() to clean the DB.
|
|
254
|
+
abort(movementId, reason) {
|
|
255
|
+
var state = this._stateFor(movementId);
|
|
256
|
+
if (state.aborted) return;
|
|
257
|
+
state.aborted = true;
|
|
258
|
+
state.abortReason = reason || 'aborted';
|
|
259
|
+
|
|
260
|
+
var kept = [];
|
|
261
|
+
for (var i = 0; i < this._items.length; i++) {
|
|
262
|
+
var it = this._items[i];
|
|
263
|
+
if (it.movementId === movementId) {
|
|
264
|
+
this._byteTotal -= it.bytes;
|
|
265
|
+
state.queued--;
|
|
266
|
+
state.dropped++;
|
|
267
|
+
} else {
|
|
268
|
+
kept.push(it);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this._items = kept;
|
|
272
|
+
this._releaseBackpressure();
|
|
273
|
+
|
|
274
|
+
try { this.onMovementAbort({ movementId: movementId, reason: state.abortReason }); }
|
|
275
|
+
catch (e) { /* swallow — never let a hook block */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Stats: per-movement if id passed, else global.
|
|
279
|
+
stats(movementId) {
|
|
280
|
+
if (movementId) {
|
|
281
|
+
var s = this._movements.get(movementId);
|
|
282
|
+
return s ? Object.assign({}, s) : null;
|
|
283
|
+
}
|
|
284
|
+
var total = { queued: this._items.length, inFlight: this._inFlight, bytes: this._byteTotal, movements: this._movements.size };
|
|
285
|
+
return total;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Resolve when the queue drains AND no items are in flight AND no
|
|
289
|
+
// retry is pending. Useful after a spool ends and before checking
|
|
290
|
+
// final counts.
|
|
291
|
+
async drain() {
|
|
292
|
+
while (this._items.length > 0 || this._inFlight > 0 || this._pendingRetries > 0) {
|
|
293
|
+
await new Promise(function(res) { this._idleWaiters.push(res); }.bind(this));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
pause() { this._paused = true; }
|
|
298
|
+
resume() { if (this._paused) { this._paused = false; this._kick(); } }
|
|
299
|
+
stop() { this._stopped = true; this._paused = true; this._releaseBackpressure(); }
|
|
300
|
+
|
|
301
|
+
// ─── internal ─────────────────────────────────────────────────────────
|
|
302
|
+
_stateFor(movementId) {
|
|
303
|
+
var s = this._movements.get(movementId);
|
|
304
|
+
if (!s) {
|
|
305
|
+
s = { movementId: movementId, queued: 0, inFlight: 0, completed: 0, dropped: 0, aborted: false, abortReason: null, consecutiveDrops: 0 };
|
|
306
|
+
this._movements.set(movementId, s);
|
|
307
|
+
}
|
|
308
|
+
return s;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async _workerLoop() {
|
|
312
|
+
while (!this._stopped) {
|
|
313
|
+
if (this._paused || this._items.length === 0 || this._byteTotal === 0) {
|
|
314
|
+
await sleep(25);
|
|
315
|
+
this._maybeSignalIdle();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Pick the first item whose movement isn't aborted.
|
|
319
|
+
var idx = -1;
|
|
320
|
+
for (var i = 0; i < this._items.length; i++) {
|
|
321
|
+
var m = this._items[i].movementId;
|
|
322
|
+
if (!this._stateFor(m).aborted) { idx = i; break; }
|
|
323
|
+
}
|
|
324
|
+
if (idx < 0) { await sleep(25); continue; }
|
|
325
|
+
|
|
326
|
+
var item = this._items.splice(idx, 1)[0];
|
|
327
|
+
this._byteTotal -= item.bytes;
|
|
328
|
+
this._releaseBackpressure();
|
|
329
|
+
|
|
330
|
+
var state = this._stateFor(item.movementId);
|
|
331
|
+
state.queued--;
|
|
332
|
+
state.inFlight++;
|
|
333
|
+
this._inFlight++;
|
|
334
|
+
|
|
335
|
+
try { await this._processItem(item, state); }
|
|
336
|
+
catch (e) { /* processItem never throws — belt and braces */ }
|
|
337
|
+
finally { state.inFlight--; this._inFlight--; this._maybeSignalIdle(); }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async _processItem(item, state) {
|
|
342
|
+
if (state.aborted) return;
|
|
343
|
+
|
|
344
|
+
var conn;
|
|
345
|
+
try { conn = await this._resolveConnection(item.connection); }
|
|
346
|
+
catch (err) {
|
|
347
|
+
// Connection resolution failure = fatal for the movement.
|
|
348
|
+
state.dropped++;
|
|
349
|
+
this.abort(item.movementId, 'connection_resolve_failed: ' + err.message);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
item.attempt++;
|
|
354
|
+
try {
|
|
355
|
+
await this.executor(item, conn);
|
|
356
|
+
state.completed++;
|
|
357
|
+
state.consecutiveDrops = 0;
|
|
358
|
+
return;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
var attemptedFinal = item.attempt >= this.maxAttempts;
|
|
361
|
+
var decision = await this.errorExecutor({ item: item, error: err, attempt: item.attempt, isFinal: attemptedFinal });
|
|
362
|
+
decision = decision || {};
|
|
363
|
+
|
|
364
|
+
if (decision.decision === 'retry' && !attemptedFinal && !state.aborted) {
|
|
365
|
+
// Delays indexed by "which retry": 1st retry uses [0], 2nd uses [1], etc.
|
|
366
|
+
var delayIdx = Math.min(item.attempt - 1, this.retryDelaysMs.length - 1);
|
|
367
|
+
var delay = decision.delayMs != null ? decision.delayMs : this.retryDelaysMs[delayIdx];
|
|
368
|
+
this._pendingRetries++;
|
|
369
|
+
setTimeout(function() {
|
|
370
|
+
this._pendingRetries--;
|
|
371
|
+
if (!state.aborted && !this._stopped) {
|
|
372
|
+
item.bytes = approximateBytes(item);
|
|
373
|
+
this._byteTotal += item.bytes;
|
|
374
|
+
this._items.push(item);
|
|
375
|
+
state.queued++;
|
|
376
|
+
}
|
|
377
|
+
this._maybeSignalIdle();
|
|
378
|
+
}.bind(this), delay);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (decision.decision === 'bisect' && item.meta && Array.isArray(item.meta.rows) && item.meta.rows.length > 1) {
|
|
383
|
+
var mid = Math.floor(item.meta.rows.length / 2);
|
|
384
|
+
var left = cloneItemWithRows(item, item.meta.rows.slice(0, mid));
|
|
385
|
+
var right = cloneItemWithRows(item, item.meta.rows.slice(mid));
|
|
386
|
+
await this.addToQueue(left);
|
|
387
|
+
await this.addToQueue(right);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (decision.decision === 'fatal') {
|
|
392
|
+
this.abort(item.movementId, 'fatal: ' + (decision.reason || err.message));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 'error-table' | 'drop' | anything else past max attempts → drop
|
|
397
|
+
state.dropped++;
|
|
398
|
+
state.consecutiveDrops++;
|
|
399
|
+
try {
|
|
400
|
+
this.onErrorTable({
|
|
401
|
+
item: item,
|
|
402
|
+
error: err,
|
|
403
|
+
rowNum: item.meta && item.meta.rowNum,
|
|
404
|
+
reason: decision.reason || err.message
|
|
405
|
+
});
|
|
406
|
+
} catch (_) { /* swallow */ }
|
|
407
|
+
if (state.consecutiveDrops >= this.maxConsecutiveDrops) {
|
|
408
|
+
this.abort(item.movementId, 'consecutive_drops_exceeded');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async _resolveConnection(name) {
|
|
414
|
+
if (this._resolved[name]) return this._resolved[name];
|
|
415
|
+
var raw = this.connections[name];
|
|
416
|
+
if (raw === undefined) throw new Error('SqlQueue: unknown connection "' + name + '"');
|
|
417
|
+
if (typeof raw === 'object') { this._resolved[name] = raw; return raw; }
|
|
418
|
+
if (typeof raw !== 'string') throw new Error('SqlQueue: connection "' + name + '" must be encrypted string or plain object');
|
|
419
|
+
if (!this.encryptionKey) throw new Error('SqlQueue: encryptionKey required to decrypt connection "' + name + '"');
|
|
420
|
+
var { decrypt } = require('../isomorphic/crypto');
|
|
421
|
+
var decrypted = await decrypt(raw, this.encryptionKey);
|
|
422
|
+
var config = JSON.parse(decrypted);
|
|
423
|
+
this._resolved[name] = config;
|
|
424
|
+
return config;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_releaseBackpressure() {
|
|
428
|
+
if (this._byteTotal <= this.lowMemoryBytes && this._pressureWaiters.length) {
|
|
429
|
+
var waiters = this._pressureWaiters;
|
|
430
|
+
this._pressureWaiters = [];
|
|
431
|
+
for (var i = 0; i < waiters.length; i++) waiters[i]();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
_maybeSignalIdle() {
|
|
436
|
+
if (this._items.length === 0 && this._inFlight === 0 && this._pendingRetries === 0 && this._idleWaiters.length) {
|
|
437
|
+
var waiters = this._idleWaiters;
|
|
438
|
+
this._idleWaiters = [];
|
|
439
|
+
for (var i = 0; i < waiters.length; i++) waiters[i]();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_kick() { /* worker loops poll — nothing to do */ }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function defaultErrorExecutor(ctx) {
|
|
447
|
+
// Default: retry until final attempt, then route the row (or the whole
|
|
448
|
+
// batch if not bisectable) to the error table.
|
|
449
|
+
if (!ctx.isFinal) return { decision: 'retry' };
|
|
450
|
+
if (ctx.item.meta && Array.isArray(ctx.item.meta.rows) && ctx.item.meta.rows.length > 1) {
|
|
451
|
+
return { decision: 'bisect' };
|
|
452
|
+
}
|
|
453
|
+
return { decision: 'error-table', reason: ctx.error && ctx.error.message };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function cloneItemWithRows(item, rows) {
|
|
457
|
+
var clonedMeta = Object.assign({}, item.meta, { rows: rows, rowCount: rows.length });
|
|
458
|
+
var next = {
|
|
459
|
+
movementId: item.movementId,
|
|
460
|
+
connection: item.connection,
|
|
461
|
+
meta: clonedMeta,
|
|
462
|
+
attempt: 0
|
|
463
|
+
};
|
|
464
|
+
if (typeof clonedMeta.rowsToSql === 'function') {
|
|
465
|
+
var built = clonedMeta.rowsToSql(rows);
|
|
466
|
+
if (typeof built === 'string') { next.sql = built; next.params = item.params; }
|
|
467
|
+
else if (built && built.sql) { next.sql = built.sql; next.params = built.params; }
|
|
468
|
+
} else {
|
|
469
|
+
next.sql = item.sql; next.params = item.params;
|
|
470
|
+
}
|
|
471
|
+
return next;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function approximateBytes(item) {
|
|
475
|
+
try { return JSON.stringify(item).length; } catch (_) { return 128; }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function sleep(ms) { return new Promise(function(res) { setTimeout(res, ms); }); }
|
|
479
|
+
|
|
160
480
|
module.exports = Queue;
|
|
481
|
+
module.exports.Queue = Queue;
|
|
482
|
+
module.exports.SqlQueue = SqlQueue;
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Utility functions: email (SMTP, AWS SES, Azure, Brevo), cache, queue, logging client, and helpers",
|
|
5
5
|
"main": "index.js",
|
|
6
|
-
"files": [
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib/",
|
|
9
|
+
"isomorphic/"
|
|
10
|
+
],
|
|
7
11
|
"exports": {
|
|
8
12
|
".": "./index.js",
|
|
9
13
|
"./isomorphic": "./isomorphic/index.js",
|
|
@@ -15,26 +19,55 @@
|
|
|
15
19
|
"./lib/fileUploader": "./lib/fileUploader.js",
|
|
16
20
|
"./lib/helpers": "./lib/helpers.js",
|
|
17
21
|
"./lib/logger": "./lib/logger.js",
|
|
22
|
+
"./lib/otp": "./lib/otp.js",
|
|
18
23
|
"./lib/queue": "./lib/queue.js",
|
|
19
24
|
"./lib/rateLimiter": "./lib/rateLimiter.js",
|
|
20
|
-
"./lib/response": "./lib/response.js"
|
|
25
|
+
"./lib/response": "./lib/response.js",
|
|
26
|
+
"./lib/sms": "./lib/sms.js"
|
|
21
27
|
},
|
|
22
|
-
"keywords": [
|
|
28
|
+
"keywords": [
|
|
29
|
+
"email",
|
|
30
|
+
"brevo",
|
|
31
|
+
"ses",
|
|
32
|
+
"smtp",
|
|
33
|
+
"cache",
|
|
34
|
+
"redis",
|
|
35
|
+
"queue",
|
|
36
|
+
"logger",
|
|
37
|
+
"utilities"
|
|
38
|
+
],
|
|
23
39
|
"author": "xeplr",
|
|
24
40
|
"license": "MIT",
|
|
25
|
-
"repository": {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/Xeplr/xeplr-utils"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
31
47
|
},
|
|
48
|
+
"dependencies": {},
|
|
32
49
|
"peerDependencies": {
|
|
33
50
|
"@aws-sdk/client-ses": "^3.0.0",
|
|
34
|
-
"@azure/communication-email": "^1.0.0"
|
|
51
|
+
"@azure/communication-email": "^1.0.0",
|
|
52
|
+
"ioredis": "^5.6.1",
|
|
53
|
+
"multer": "^1.4.5-lts.1",
|
|
54
|
+
"nodemailer": "^8.0.2"
|
|
35
55
|
},
|
|
36
56
|
"peerDependenciesMeta": {
|
|
37
|
-
"@aws-sdk/client-ses": {
|
|
38
|
-
|
|
57
|
+
"@aws-sdk/client-ses": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"@azure/communication-email": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"ioredis": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"multer": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"nodemailer": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
39
72
|
}
|
|
40
73
|
}
|