@xeplr/utils 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ var MemoryStore = {
2
+ create: function(windowInSeconds) {
3
+ var hits = {};
4
+
5
+ // cleanup expired keys periodically
6
+ var cleanup = setInterval(function() {
7
+ var now = Date.now();
8
+ for (var key in hits) {
9
+ if (hits[key].expiresAt < now) {
10
+ delete hits[key];
11
+ }
12
+ }
13
+ }, windowInSeconds * 1000);
14
+
15
+ if (cleanup && cleanup.unref) cleanup.unref();
16
+
17
+ return {
18
+ increment: async function(key) {
19
+ var now = Date.now();
20
+ var entry = hits[key];
21
+ if (!entry || entry.expiresAt < now) {
22
+ hits[key] = { count: 1, expiresAt: now + windowInSeconds * 1000 };
23
+ return { count: 1, remaining: windowInSeconds };
24
+ }
25
+ entry.count++;
26
+ var remaining = Math.ceil((entry.expiresAt - now) / 1000);
27
+ return { count: entry.count, remaining: remaining };
28
+ }
29
+ };
30
+ }
31
+ };
32
+
33
+ var RedisStore = {
34
+ create: function(windowInSeconds) {
35
+ var cache = require('./cache');
36
+ var client = cache.getClient();
37
+
38
+ return {
39
+ increment: async function(key) {
40
+ var redisKey = 'xeplr:ratelimit:' + key;
41
+ var count = await client.incr(redisKey);
42
+ if (count === 1) {
43
+ await client.expire(redisKey, windowInSeconds);
44
+ }
45
+ var ttl = await client.ttl(redisKey);
46
+ return { count: count, remaining: ttl > 0 ? ttl : windowInSeconds };
47
+ }
48
+ };
49
+ }
50
+ };
51
+
52
+ class RateLimiter {
53
+ /**
54
+ * @param {object} options
55
+ * @param {number} [options.windowInSeconds=60] - Time window in seconds
56
+ * @param {number} [options.max=100] - Max requests per window
57
+ * @param {string} [options.store='memory'] - 'memory' or 'redis'
58
+ * @param {function} [options.keyFn] - Function to extract key from req (default: req.ip)
59
+ * @param {string} [options.message] - Custom error message
60
+ */
61
+ constructor(options = {}) {
62
+ this._windowInSeconds = options.windowInSeconds || 60;
63
+ this._max = options.max || 100;
64
+ this._keyFn = options.keyFn || function(req) { return req.ip; };
65
+ this._message = options.message || 'Too many requests, please try again later';
66
+
67
+ var storeType = options.store || 'memory';
68
+ if (storeType === 'redis') {
69
+ this._store = RedisStore.create(this._windowInSeconds);
70
+ } else {
71
+ this._store = MemoryStore.create(this._windowInSeconds);
72
+ }
73
+ }
74
+
75
+ middleware() {
76
+ var self = this;
77
+ return async function(req, res, next) {
78
+ try {
79
+ var key = self._keyFn(req);
80
+ var result = await self._store.increment(key);
81
+
82
+ res.setHeader('X-RateLimit-Limit', self._max);
83
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, self._max - result.count));
84
+ res.setHeader('X-RateLimit-Reset', result.remaining);
85
+
86
+ if (result.count > self._max) {
87
+ res.setHeader('Retry-After', result.remaining);
88
+ return res.status(429).json({ error: self._message });
89
+ }
90
+
91
+ next();
92
+ } catch (err) {
93
+ // if rate limiter fails, let the request through
94
+ next();
95
+ }
96
+ };
97
+ }
98
+ }
99
+
100
+ module.exports = RateLimiter;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Response helper — sends a fixed-format JSON response.
3
+ *
4
+ * Format:
5
+ * {
6
+ * code: 'SUC_LOGIN' | 'ERR_INVALID_FILE' | STATUS.SUCCESS | ...,
7
+ * message: 'Human-readable message (i18n aware)',
8
+ * error: null | { name, message, ...details },
9
+ * dataArray: [],
10
+ * updatedIds: []
11
+ * }
12
+ *
13
+ * Usage:
14
+ * const { respond } = require('@xeplr/utils');
15
+ * const { HTTP, STATUS } = require('@xeplr/utils/isomorphic');
16
+ *
17
+ * // Success
18
+ * respond(res, HTTP.OK, STATUS.SUCCESS, 'success', { dataArray: orders });
19
+ *
20
+ * // Error
21
+ * respond(res, HTTP.BAD_REQUEST, STATUS.BAD_REQUEST, 'bad_request', { error: err });
22
+ *
23
+ * // With updatedIds
24
+ * respond(res, HTTP.OK, STATUS.UPDATED, 'updated', { updatedIds: [1, 2, 3] });
25
+ *
26
+ * // Minimal
27
+ * respond(res, HTTP.OK, STATUS.SUCCESS, 'success');
28
+ */
29
+
30
+ const { msg } = require('../isomorphic');
31
+
32
+ /**
33
+ * Sanitize an error object for serialization (strips stack trace).
34
+ */
35
+ function sanitizeError(err) {
36
+ if (!err) return null;
37
+
38
+ const clean = {
39
+ name: err.name || 'Error',
40
+ message: err.message || String(err),
41
+ };
42
+
43
+ // Preserve any extra properties (e.g. err.code, err.field, err.details)
44
+ // but skip stack, __proto__, constructor
45
+ const skip = new Set(['name', 'message', 'stack']);
46
+ for (const key of Object.keys(err)) {
47
+ if (!skip.has(key)) {
48
+ clean[key] = err[key];
49
+ }
50
+ }
51
+
52
+ return clean;
53
+ }
54
+
55
+ /**
56
+ * Send a standardized response.
57
+ *
58
+ * @param {object} res - Express response object
59
+ * @param {number} httpCode - HTTP status code (from HTTP constants)
60
+ * @param {string} statusCode - Application status code (from STATUS constants)
61
+ * @param {string} messageKey - Key in MESSAGES for i18n lookup
62
+ * @param {object} [options] - Optional overrides
63
+ * @param {Array} [options.dataArray] - Response data (default: [])
64
+ * @param {Array} [options.updatedIds] - IDs affected by the operation (default: [])
65
+ * @param {Error} [options.error] - Error object, sanitized before sending (default: null)
66
+ * @param {string} [options.message] - Override the i18n message with a custom string
67
+ * @param {object} [options.pagination] - { page, limit, total, totalPages }
68
+ */
69
+ function respond(res, httpCode, statusCode, messageKey, options = {}) {
70
+ const body = {
71
+ code: statusCode,
72
+ message: options.message || msg(messageKey),
73
+ error: sanitizeError(options.error),
74
+ dataArray: options.dataArray || [],
75
+ updatedIds: options.updatedIds || [],
76
+ sessionId: res.req && res.req.sessionId || null,
77
+ };
78
+
79
+ if (options.pagination) {
80
+ body.pagination = options.pagination;
81
+ }
82
+
83
+ return res.status(httpCode).send(body);
84
+ }
85
+
86
+ module.exports = { respond, sanitizeError };
package/lib/sms.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * SMS Service — generic wrapper for any SMS provider.
3
+ *
4
+ * Each provider is an HTTP API definition with multiple actions.
5
+ * Register providers once, then send via provider + action name.
6
+ *
7
+ * Usage:
8
+ *
9
+ * var sms = require('@xeplr/utils').sms;
10
+ *
11
+ * // Simple provider — single action (default)
12
+ * sms.register('twilio', {
13
+ * actions: {
14
+ * send: {
15
+ * url: 'https://api.twilio.com/2010-04-01/Accounts/{{accountSid}}/Messages.json',
16
+ * method: 'POST',
17
+ * auth: { user: '{{accountSid}}', password: '{{authToken}}' },
18
+ * contentType: 'form',
19
+ * body: { From: '{{from}}', To: '{{to}}', Body: '{{message}}' }
20
+ * }
21
+ * },
22
+ * params: { accountSid: 'AC...', authToken: '...', from: '+1234567890' }
23
+ * });
24
+ *
25
+ * // Provider with multiple actions + chained auth
26
+ * sms.register('kaleyra', {
27
+ * actions: {
28
+ * auth: {
29
+ * url: 'https://api.kaleyra.io/v1/{{sid}}/auth/token',
30
+ * method: 'POST',
31
+ * contentType: 'json',
32
+ * body: { api_key: '{{apiKey}}' },
33
+ * extract: { token: 'data.token' } // extract token from response into params
34
+ * },
35
+ * otp: {
36
+ * url: 'https://api.kaleyra.io/v1/{{sid}}/messages',
37
+ * method: 'POST',
38
+ * headers: { Authorization: 'Bearer {{token}}' },
39
+ * contentType: 'json',
40
+ * body: { to: '{{to}}', type: 'OTP', body: '{{message}}' },
41
+ * before: ['auth'] // run auth action first, merge extracted params
42
+ * },
43
+ * promo: {
44
+ * url: 'https://api.kaleyra.io/v1/{{sid}}/messages',
45
+ * method: 'POST',
46
+ * headers: { Authorization: 'Bearer {{token}}' },
47
+ * contentType: 'json',
48
+ * body: { to: '{{to}}', type: 'PROMO', body: '{{message}}' },
49
+ * before: ['auth']
50
+ * }
51
+ * },
52
+ * params: { sid: '...', apiKey: '...' }
53
+ * });
54
+ *
55
+ * // Send
56
+ * await sms.send('twilio', { to: '+91...', message: 'Hello!' });
57
+ * await sms.send('kaleyra', 'otp', { to: '+91...', message: 'OTP: 1234' });
58
+ * await sms.send('kaleyra', 'promo', { to: '+91...', message: 'Sale!' });
59
+ */
60
+
61
+ var _providers = {};
62
+ var _defaultProvider = null;
63
+
64
+ /**
65
+ * Register an SMS provider.
66
+ *
67
+ * @param {string} name - Provider name
68
+ * @param {object} config
69
+ * @param {object} config.actions - Named actions { actionName: { url, method, headers, auth, contentType, body, before, extract } }
70
+ * @param {object} [config.params] - Default params shared across all actions
71
+ * @param {function} [config.parseResponse] - Custom response parser for all actions
72
+ */
73
+ function register(name, config) {
74
+ _providers[name] = config;
75
+ }
76
+
77
+ /**
78
+ * Set the default provider.
79
+ */
80
+ function setDefault(name) {
81
+ _defaultProvider = name;
82
+ }
83
+
84
+ function _resolvePlaceholders(str, params) {
85
+ if (typeof str !== 'string') return str;
86
+ return str.replace(/\{\{(\w+)\}\}/g, function(match, key) {
87
+ return params[key] !== undefined ? String(params[key]) : match;
88
+ });
89
+ }
90
+
91
+ function _resolveObject(obj, params) {
92
+ if (typeof obj === 'string') return _resolvePlaceholders(obj, params);
93
+ if (Array.isArray(obj)) return obj.map(function(v) { return _resolveObject(v, params); });
94
+ if (obj && typeof obj === 'object') {
95
+ var resolved = {};
96
+ var keys = Object.keys(obj);
97
+ for (var i = 0; i < keys.length; i++) {
98
+ resolved[keys[i]] = _resolveObject(obj[keys[i]], params);
99
+ }
100
+ return resolved;
101
+ }
102
+ return obj;
103
+ }
104
+
105
+ /**
106
+ * Extract a nested value from an object using dot notation.
107
+ * e.g. _extractPath({ data: { token: 'abc' } }, 'data.token') => 'abc'
108
+ */
109
+ function _extractPath(obj, path) {
110
+ var parts = path.split('.');
111
+ var val = obj;
112
+ for (var i = 0; i < parts.length; i++) {
113
+ if (val == null) return undefined;
114
+ val = val[parts[i]];
115
+ }
116
+ return val;
117
+ }
118
+
119
+ /**
120
+ * Execute a single action against a provider.
121
+ */
122
+ async function _executeAction(action, params) {
123
+ var url = _resolvePlaceholders(action.url, params);
124
+ var method = (action.method || 'POST').toUpperCase();
125
+
126
+ var headers = {};
127
+ if (action.headers) {
128
+ headers = _resolveObject(action.headers, params);
129
+ }
130
+
131
+ var fetchOptions = { method: method, headers: {} };
132
+
133
+ // Basic auth
134
+ if (action.auth) {
135
+ var user = _resolvePlaceholders(action.auth.user, params);
136
+ var password = _resolvePlaceholders(action.auth.password, params);
137
+ fetchOptions.headers['Authorization'] = 'Basic ' + Buffer.from(user + ':' + password).toString('base64');
138
+ }
139
+
140
+ Object.assign(fetchOptions.headers, headers);
141
+
142
+ // Body
143
+ if (action.body) {
144
+ var body = _resolveObject(action.body, params);
145
+ var contentType = action.contentType || 'json';
146
+
147
+ if (contentType === 'form') {
148
+ fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
149
+ var parts = [];
150
+ var bodyKeys = Object.keys(body);
151
+ for (var i = 0; i < bodyKeys.length; i++) {
152
+ parts.push(encodeURIComponent(bodyKeys[i]) + '=' + encodeURIComponent(body[bodyKeys[i]]));
153
+ }
154
+ fetchOptions.body = parts.join('&');
155
+ } else {
156
+ fetchOptions.headers['Content-Type'] = 'application/json';
157
+ fetchOptions.body = JSON.stringify(body);
158
+ }
159
+ }
160
+
161
+ var res = await fetch(url, fetchOptions);
162
+ var raw;
163
+ try {
164
+ raw = await res.json();
165
+ } catch (e) {
166
+ raw = null;
167
+ }
168
+
169
+ return { ok: res.ok, status: res.status, raw: raw };
170
+ }
171
+
172
+ /**
173
+ * Send an SMS.
174
+ *
175
+ * Signatures:
176
+ * send('twilio', { to, message }) — uses 'send' action
177
+ * send('kaleyra', 'otp', { to, message }) — uses named action
178
+ * send({ to, message }) — uses default provider + 'send' action
179
+ *
180
+ * @returns {Promise<{ success, messageId?, raw?, error? }>}
181
+ */
182
+ async function send(providerName, actionOrParams, sendParams) {
183
+ // Resolve arguments
184
+ var actionName, params;
185
+
186
+ if (typeof providerName === 'object') {
187
+ // send({ to, message }) — default provider, 'send' action
188
+ params = providerName;
189
+ providerName = _defaultProvider;
190
+ actionName = 'send';
191
+ } else if (typeof actionOrParams === 'string') {
192
+ // send('provider', 'action', { to, message })
193
+ actionName = actionOrParams;
194
+ params = sendParams || {};
195
+ } else {
196
+ // send('provider', { to, message }) — 'send' action
197
+ actionName = 'send';
198
+ params = actionOrParams || {};
199
+ }
200
+
201
+ if (!providerName) {
202
+ throw new Error('SMS provider name required. Call sms.setDefault() or pass provider name.');
203
+ }
204
+
205
+ var config = _providers[providerName];
206
+ if (!config) {
207
+ throw new Error('SMS provider "' + providerName + '" not registered.');
208
+ }
209
+
210
+ var action = config.actions[actionName];
211
+ if (!action) {
212
+ throw new Error('Action "' + actionName + '" not found on provider "' + providerName + '".');
213
+ }
214
+
215
+ // Merge default params with send-time params
216
+ var mergedParams = Object.assign({}, config.params || {}, params);
217
+
218
+ // Run prerequisite actions (e.g. auth)
219
+ if (action.before && action.before.length) {
220
+ for (var i = 0; i < action.before.length; i++) {
221
+ var beforeName = action.before[i];
222
+ var beforeAction = config.actions[beforeName];
223
+ if (!beforeAction) {
224
+ throw new Error('Before-action "' + beforeName + '" not found on provider "' + providerName + '".');
225
+ }
226
+
227
+ var beforeResult = await _executeAction(beforeAction, mergedParams);
228
+ if (!beforeResult.ok) {
229
+ return {
230
+ success: false,
231
+ error: 'Pre-action "' + beforeName + '" failed with status ' + beforeResult.status,
232
+ raw: beforeResult.raw
233
+ };
234
+ }
235
+
236
+ // Extract values from response into params
237
+ if (beforeAction.extract && beforeResult.raw) {
238
+ var extractKeys = Object.keys(beforeAction.extract);
239
+ for (var j = 0; j < extractKeys.length; j++) {
240
+ var paramKey = extractKeys[j];
241
+ var responsePath = beforeAction.extract[paramKey];
242
+ mergedParams[paramKey] = _extractPath(beforeResult.raw, responsePath);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ // Execute main action
249
+ var result = await _executeAction(action, mergedParams);
250
+
251
+ // Custom response parser
252
+ if (config.parseResponse) {
253
+ return config.parseResponse(result.raw, result);
254
+ }
255
+
256
+ if (!result.ok) {
257
+ return {
258
+ success: false,
259
+ error: (result.raw && (result.raw.message || result.raw.error || result.raw.error_message)) || 'SMS failed with status ' + result.status,
260
+ raw: result.raw
261
+ };
262
+ }
263
+
264
+ return {
265
+ success: true,
266
+ messageId: result.raw && (result.raw.sid || result.raw.messageId || result.raw.message_id || result.raw.id) || null,
267
+ raw: result.raw
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Get list of registered provider names.
273
+ */
274
+ function getProviders() {
275
+ return Object.keys(_providers);
276
+ }
277
+
278
+ module.exports = { register, setDefault, send, getProviders };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@xeplr/utils",
3
+ "version": "1.0.0",
4
+ "description": "Utility functions: email (SMTP, AWS SES, Azure, Brevo), cache, queue, logging client, and helpers",
5
+ "main": "index.js",
6
+ "files": ["index.js", "lib/", "isomorphic/"],
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./isomorphic": "./isomorphic/index.js",
10
+ "./isomorphic/crypto": "./isomorphic/crypto.js",
11
+ "./isomorphic/countries": "./isomorphic/countries.js",
12
+ "./isomorphic/states": "./isomorphic/states.js",
13
+ "./lib/cache": "./lib/cache.js",
14
+ "./lib/email": "./lib/email.js",
15
+ "./lib/fileUploader": "./lib/fileUploader.js",
16
+ "./lib/helpers": "./lib/helpers.js",
17
+ "./lib/logger": "./lib/logger.js",
18
+ "./lib/queue": "./lib/queue.js",
19
+ "./lib/rateLimiter": "./lib/rateLimiter.js",
20
+ "./lib/response": "./lib/response.js"
21
+ },
22
+ "keywords": ["email", "brevo", "ses", "smtp", "cache", "redis", "queue", "logger", "utilities"],
23
+ "author": "xeplr",
24
+ "license": "MIT",
25
+ "repository": { "type": "git", "url": "https://github.com/Xeplr/xeplr-utils" },
26
+ "publishConfig": { "access": "public" },
27
+ "dependencies": {
28
+ "ioredis": "^5.6.1",
29
+ "multer": "^1.4.5-lts.1",
30
+ "nodemailer": "^8.0.2"
31
+ },
32
+ "peerDependencies": {
33
+ "@aws-sdk/client-ses": "^3.0.0",
34
+ "@azure/communication-email": "^1.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@aws-sdk/client-ses": { "optional": true },
38
+ "@azure/communication-email": { "optional": true }
39
+ }
40
+ }