b2b-platform-utils 1.1.95 → 1.1.96

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/README.md CHANGED
@@ -8,6 +8,29 @@ This package centralizes small, repeated helpers used across services so they ca
8
8
 
9
9
  ---
10
10
 
11
+ ## Service lifecycle
12
+
13
+ `lifecycle.js` provides single-flight bootstrap, bounded dependency retry with exponential
14
+ backoff and jitter, structured lifecycle events, readiness state, and ordered bounded
15
+ shutdown hooks. Services keep ownership of their dependency order and cleanup actions.
16
+
17
+ ```js
18
+ const { createLifecycle } = require('b2b-platform-utils/lifecycle');
19
+
20
+ const lifecycle = createLifecycle({ service: 'example' });
21
+ lifecycle.registerSignalHandlers();
22
+ lifecycle.registerFatalHandlers();
23
+
24
+ void lifecycle.bootstrap(async () => {
25
+ await lifecycle.connect('config', ({ deadline }) => loadConfig({ deadline }));
26
+ });
27
+ ```
28
+
29
+ Lifecycle and dependency events are emitted as one JSON object per line and include the
30
+ process instance identity, deployment version, phase, and timing fields.
31
+
32
+ ---
33
+
11
34
  ## Install
12
35
 
13
36
  ```bash
package/lifecycle.js ADDED
@@ -0,0 +1,482 @@
1
+ 'use strict';
2
+
3
+ const { structuredEvent } = require('./logger');
4
+ const { setService } = require('./localCache');
5
+
6
+ const STATES = Object.freeze({
7
+ STARTING: 'STARTING',
8
+ INITIALIZING: 'INITIALIZING',
9
+ READY: 'READY',
10
+ RUNNING: 'RUNNING',
11
+ DRAINING: 'DRAINING',
12
+ TERMINATED: 'TERMINATED',
13
+ });
14
+
15
+ const TRANSIENT_GRPC_CODES = new Set([4, 8, 13, 14]);
16
+ const PERMANENT_GRPC_CODES = new Set([3, 5, 6, 7, 9, 11, 12, 16]);
17
+ const TRANSIENT_ERROR_CODES = new Set([
18
+ 'ECONNABORTED',
19
+ 'ECONNREFUSED',
20
+ 'ECONNRESET',
21
+ 'EHOSTUNREACH',
22
+ 'ENETDOWN',
23
+ 'ENETUNREACH',
24
+ 'ENOTFOUND',
25
+ 'EPIPE',
26
+ 'ETIMEDOUT',
27
+ ]);
28
+
29
+ let activeLifecycle = null;
30
+
31
+ function errorReason(error) {
32
+ const value = error?.details || error?.message || error || 'unknown';
33
+ return String(value)
34
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi, '$1[redacted]@')
35
+ .slice(0, 1000);
36
+ }
37
+
38
+ function classifyDependencyError(error) {
39
+ if (error?.permanent === true) return 'permanent';
40
+ if (error?.transient === true) return 'transient';
41
+ if (error instanceof SyntaxError) return 'permanent';
42
+
43
+ const code = error?.code;
44
+ if (TRANSIENT_GRPC_CODES.has(code) || TRANSIENT_ERROR_CODES.has(code)) {
45
+ return 'transient';
46
+ }
47
+ if (PERMANENT_GRPC_CODES.has(code)) {
48
+ return 'permanent';
49
+ }
50
+
51
+ const name = String(error?.name || '');
52
+ if (
53
+ /Connection|ConnectionRefused|HostNotFound|MongoServerSelection|RedisSocketClosed|Timeout/i.test(name)
54
+ ) {
55
+ return 'transient';
56
+ }
57
+ if (/AccessDenied|Authentication|Authorization|Validation/i.test(name)) {
58
+ return 'permanent';
59
+ }
60
+
61
+ const reason = errorReason(error);
62
+ if (/timeout|timed out|deadline exceeded|temporar|unavailable|connection (?:closed|lost|refused|reset)/i.test(reason)) {
63
+ return 'transient';
64
+ }
65
+ if (/access denied|authentication|authorization|permission denied|invalid config|required .* missing|malformed/i.test(reason)) {
66
+ return 'permanent';
67
+ }
68
+ return 'permanent';
69
+ }
70
+
71
+ function sleep(ms, signal) {
72
+ return new Promise((resolve, reject) => {
73
+ if (signal.aborted) {
74
+ reject(Object.assign(new Error('operation aborted'), { name: 'AbortError' }));
75
+ return;
76
+ }
77
+ let timer = null;
78
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
79
+ const onElapsed = () => {
80
+ cleanup();
81
+ resolve();
82
+ };
83
+ const onAbort = () => {
84
+ clearTimeout(timer);
85
+ cleanup();
86
+ reject(Object.assign(new Error('operation aborted'), { name: 'AbortError' }));
87
+ };
88
+ timer = setTimeout(onElapsed, ms);
89
+ signal.addEventListener('abort', onAbort, { once: true });
90
+ });
91
+ }
92
+
93
+ function withTimeout(operation, timeoutMs, parentSignal, attempt) {
94
+ if (!timeoutMs) {
95
+ return operation({
96
+ attempt,
97
+ signal: parentSignal,
98
+ deadline: null,
99
+ });
100
+ }
101
+
102
+ const controller = new AbortController();
103
+ const onParentAbort = () => controller.abort(parentSignal.reason);
104
+ parentSignal.addEventListener('abort', onParentAbort, { once: true });
105
+ let timer = null;
106
+
107
+ const timeoutPromise = new Promise((_, reject) => {
108
+ timer = setTimeout(() => {
109
+ const timeoutError = Object.assign(
110
+ new Error(`dependency attempt timed out after ${timeoutMs}ms`),
111
+ { code: 'ETIMEDOUT', transient: true },
112
+ );
113
+ controller.abort(timeoutError);
114
+ reject(timeoutError);
115
+ }, timeoutMs);
116
+ });
117
+
118
+ return Promise.race([
119
+ Promise.resolve().then(() => operation({
120
+ attempt,
121
+ signal: controller.signal,
122
+ deadline: Date.now() + timeoutMs,
123
+ })),
124
+ timeoutPromise,
125
+ ]).finally(() => {
126
+ if (timer) clearTimeout(timer);
127
+ parentSignal.removeEventListener('abort', onParentAbort);
128
+ });
129
+ }
130
+
131
+ function bounded(action, timeoutMs) {
132
+ let timer = null;
133
+ const timeout = new Promise((_, reject) => {
134
+ timer = setTimeout(
135
+ () => reject(Object.assign(new Error('shutdown step timed out'), { code: 'ETIMEDOUT' })),
136
+ timeoutMs,
137
+ );
138
+ });
139
+ return Promise.race([Promise.resolve().then(action), timeout]).finally(() => {
140
+ if (timer) clearTimeout(timer);
141
+ });
142
+ }
143
+
144
+ function createLifecycle({
145
+ service,
146
+ shutdownTimeoutMs = 25000,
147
+ exitOnFatal = true,
148
+ } = {}) {
149
+ if (!service || typeof service !== 'string') {
150
+ throw new Error('lifecycle service is required');
151
+ }
152
+ setService(service);
153
+
154
+ const startedAt = Date.now();
155
+ const abortController = new AbortController();
156
+ const dependencyPromises = new Map();
157
+ const dependencyResults = new Map();
158
+ const dependencyStatus = new Map();
159
+ const shutdownSteps = new Map();
160
+ let phase = STATES.STARTING;
161
+ let bootstrapCompleted = false;
162
+ let bootstrapPromise = null;
163
+ let shutdownPromise = null;
164
+ let fatalStarted = false;
165
+ let signalsRegistered = false;
166
+ let fatalHandlersRegistered = false;
167
+
168
+ function emit(event, fields = {}) {
169
+ structuredEvent(event, {
170
+ phase,
171
+ ...fields,
172
+ });
173
+ }
174
+
175
+ function setPhase(nextPhase) {
176
+ phase = nextPhase;
177
+ }
178
+
179
+ function allEssentialDependenciesReady() {
180
+ for (const value of dependencyStatus.values()) {
181
+ if (value.essential && !value.ready) return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ function markReady(reason = 'bootstrap_completed') {
187
+ if (!bootstrapCompleted || !allEssentialDependenciesReady() || phase === STATES.DRAINING) {
188
+ return;
189
+ }
190
+ if (phase !== STATES.READY && phase !== STATES.RUNNING) {
191
+ setPhase(STATES.READY);
192
+ emit('lifecycle.ready', { reason });
193
+ }
194
+ setPhase(STATES.RUNNING);
195
+ }
196
+
197
+ function markNotReady(reason, dependency = null) {
198
+ if (phase === STATES.DRAINING || phase === STATES.TERMINATED) return;
199
+ const wasReady = phase === STATES.READY || phase === STATES.RUNNING;
200
+ setPhase(STATES.INITIALIZING);
201
+ if (wasReady) {
202
+ emit('lifecycle.not_ready', {
203
+ severity: 'WARN',
204
+ reason: errorReason(reason),
205
+ dependency,
206
+ });
207
+ }
208
+ }
209
+
210
+ async function connect(dependency, operation, options = {}) {
211
+ if (!dependency || typeof operation !== 'function') {
212
+ throw new Error('dependency and operation are required');
213
+ }
214
+ if (dependencyResults.has(dependency)) {
215
+ return dependencyResults.get(dependency);
216
+ }
217
+ if (dependencyPromises.has(dependency)) {
218
+ return dependencyPromises.get(dependency);
219
+ }
220
+
221
+ const {
222
+ essential = true,
223
+ attemptTimeoutMs = 10000,
224
+ initialDelayMs = 1000,
225
+ maxDelayMs = 30000,
226
+ jitterRatio = 0.2,
227
+ classifyError = classifyDependencyError,
228
+ validate = (value) => value !== false && value !== null,
229
+ } = options;
230
+ dependencyStatus.set(dependency, {
231
+ essential,
232
+ ready: false,
233
+ outageStartedAt: Date.now(),
234
+ });
235
+
236
+ const promise = (async () => {
237
+ let attempt = 0;
238
+ while (!abortController.signal.aborted) {
239
+ attempt += 1;
240
+ const attemptStartedAt = Date.now();
241
+ emit('dependency.connect_started', { dependency, attempt });
242
+ try {
243
+ const result = await withTimeout(
244
+ operation,
245
+ attemptTimeoutMs,
246
+ abortController.signal,
247
+ attempt,
248
+ );
249
+ if (!validate(result)) {
250
+ const invalidResult = Object.assign(
251
+ new Error(`${dependency} initialization returned an unusable result`),
252
+ { permanent: true },
253
+ );
254
+ throw invalidResult;
255
+ }
256
+ const status = dependencyStatus.get(dependency);
257
+ status.ready = true;
258
+ const outageMs = Date.now() - status.outageStartedAt;
259
+ emit('dependency.recovered', {
260
+ dependency,
261
+ attempt,
262
+ durationMs: Date.now() - attemptStartedAt,
263
+ outageMs,
264
+ });
265
+ markReady(`${dependency}_recovered`);
266
+ dependencyResults.set(dependency, result);
267
+ return result;
268
+ } catch (error) {
269
+ if (abortController.signal.aborted || error?.name === 'AbortError') {
270
+ throw error;
271
+ }
272
+ const classification = classifyError(error);
273
+ emit('dependency.connect_failed', {
274
+ severity: classification === 'permanent' ? 'ERROR' : 'WARN',
275
+ dependency,
276
+ attempt,
277
+ durationMs: Date.now() - attemptStartedAt,
278
+ reason: errorReason(error),
279
+ errorCode: error?.code ?? null,
280
+ });
281
+ if (classification !== 'transient') {
282
+ throw error;
283
+ }
284
+ const exponential = Math.min(
285
+ maxDelayMs,
286
+ initialDelayMs * (2 ** Math.max(0, attempt - 1)),
287
+ );
288
+ const jitter = Math.round(exponential * jitterRatio * ((Math.random() * 2) - 1));
289
+ const nextRetryMs = Math.max(1, exponential + jitter);
290
+ emit('dependency.retry_scheduled', {
291
+ severity: 'WARN',
292
+ dependency,
293
+ attempt,
294
+ nextRetryMs,
295
+ outageMs: Date.now() - dependencyStatus.get(dependency).outageStartedAt,
296
+ reason: errorReason(error),
297
+ errorCode: error?.code ?? null,
298
+ });
299
+ await sleep(nextRetryMs, abortController.signal);
300
+ }
301
+ }
302
+ throw Object.assign(new Error('dependency recovery stopped'), { name: 'AbortError' });
303
+ })().finally(() => {
304
+ dependencyPromises.delete(dependency);
305
+ });
306
+
307
+ dependencyPromises.set(dependency, promise);
308
+ return promise;
309
+ }
310
+
311
+ function dependencyDisconnected(dependency, reason, { essential = true } = {}) {
312
+ const previous = dependencyStatus.get(dependency);
313
+ dependencyResults.delete(dependency);
314
+ dependencyStatus.set(dependency, {
315
+ essential: previous?.essential ?? essential,
316
+ ready: false,
317
+ outageStartedAt: Date.now(),
318
+ });
319
+ emit('dependency.disconnected', {
320
+ severity: 'WARN',
321
+ dependency,
322
+ reason: errorReason(reason),
323
+ errorCode: reason?.code ?? null,
324
+ });
325
+ markNotReady(reason, dependency);
326
+ }
327
+
328
+ function dependencyRecovered(dependency) {
329
+ const previous = dependencyStatus.get(dependency) || {
330
+ essential: true,
331
+ outageStartedAt: Date.now(),
332
+ };
333
+ dependencyStatus.set(dependency, { ...previous, ready: true });
334
+ emit('dependency.recovered', {
335
+ dependency,
336
+ attempt: null,
337
+ durationMs: null,
338
+ outageMs: Date.now() - previous.outageStartedAt,
339
+ });
340
+ markReady(`${dependency}_recovered`);
341
+ }
342
+
343
+ function registerShutdownStep(name, action, { order = 100, timeoutMs = 5000 } = {}) {
344
+ if (!name || typeof action !== 'function') {
345
+ throw new Error('shutdown step name and action are required');
346
+ }
347
+ shutdownSteps.set(name, { name, action, order, timeoutMs });
348
+ }
349
+
350
+ async function performShutdown(exitCode, reason) {
351
+ setPhase(STATES.DRAINING);
352
+ emit('lifecycle.shutdown_started', {
353
+ reason,
354
+ exitCode,
355
+ });
356
+ abortController.abort(new Error(reason));
357
+ const shutdownStartedAt = Date.now();
358
+ const deadline = shutdownStartedAt + shutdownTimeoutMs;
359
+ const steps = [...shutdownSteps.values()].sort((a, b) => a.order - b.order);
360
+
361
+ for (const step of steps) {
362
+ const remainingMs = deadline - Date.now();
363
+ if (remainingMs <= 0) break;
364
+ try {
365
+ await bounded(step.action, Math.min(step.timeoutMs, remainingMs));
366
+ } catch (error) {
367
+ emit('lifecycle.shutdown_step_failed', {
368
+ severity: 'ERROR',
369
+ dependency: step.name,
370
+ reason: errorReason(error),
371
+ errorCode: error?.code ?? null,
372
+ });
373
+ }
374
+ }
375
+
376
+ setPhase(STATES.TERMINATED);
377
+ emit('lifecycle.shutdown_completed', {
378
+ durationMs: Date.now() - shutdownStartedAt,
379
+ exitCode,
380
+ });
381
+ process.exitCode = exitCode;
382
+ }
383
+
384
+ function shutdown(exitCode = 0, reason = 'shutdown_requested') {
385
+ if (!shutdownPromise) {
386
+ shutdownPromise = performShutdown(exitCode, reason);
387
+ }
388
+ return shutdownPromise;
389
+ }
390
+
391
+ async function fatal(error, context = 'unexpected_fatal') {
392
+ if (fatalStarted) return shutdownPromise;
393
+ fatalStarted = true;
394
+ emit('lifecycle.fatal', {
395
+ severity: 'FATAL',
396
+ reason: errorReason(error),
397
+ errorCode: error?.code ?? null,
398
+ exitCode: 1,
399
+ context,
400
+ });
401
+ await shutdown(1, context);
402
+ if (exitOnFatal) process.exit(1);
403
+ return undefined;
404
+ }
405
+
406
+ function registerSignalHandlers() {
407
+ if (signalsRegistered) return;
408
+ signalsRegistered = true;
409
+ const onSignal = (signal) => {
410
+ void shutdown(0, signal).then(() => process.exit(0));
411
+ };
412
+ process.once('SIGTERM', () => onSignal('SIGTERM'));
413
+ process.once('SIGINT', () => onSignal('SIGINT'));
414
+ }
415
+
416
+ function registerFatalHandlers() {
417
+ if (fatalHandlersRegistered) return;
418
+ fatalHandlersRegistered = true;
419
+ process.once('uncaughtException', (error) => {
420
+ void fatal(error, 'uncaught_exception');
421
+ });
422
+ process.once('unhandledRejection', (reason) => {
423
+ void fatal(reason, 'unhandled_rejection');
424
+ });
425
+ }
426
+
427
+ function bootstrap(initializer) {
428
+ if (bootstrapPromise) return bootstrapPromise;
429
+ emit('lifecycle.start', {
430
+ phase: STATES.STARTING,
431
+ durationMs: 0,
432
+ });
433
+ setPhase(STATES.INITIALIZING);
434
+ emit('lifecycle.bootstrap_started', { durationMs: 0 });
435
+ bootstrapPromise = (async () => {
436
+ try {
437
+ const result = await initializer();
438
+ bootstrapCompleted = true;
439
+ emit('lifecycle.bootstrap_completed', {
440
+ durationMs: Date.now() - startedAt,
441
+ });
442
+ markReady();
443
+ return result;
444
+ } catch (error) {
445
+ if (abortController.signal.aborted && !fatalStarted) return null;
446
+ await fatal(error, 'bootstrap_failed');
447
+ return null;
448
+ }
449
+ })();
450
+ return bootstrapPromise;
451
+ }
452
+
453
+ const lifecycle = {
454
+ bootstrap,
455
+ connect,
456
+ dependencyDisconnected,
457
+ dependencyRecovered,
458
+ emit,
459
+ getPhase: () => phase,
460
+ isReady: () => phase === STATES.READY || phase === STATES.RUNNING,
461
+ markNotReady,
462
+ registerFatalHandlers,
463
+ registerShutdownStep,
464
+ registerSignalHandlers,
465
+ shutdown,
466
+ signal: abortController.signal,
467
+ };
468
+ activeLifecycle = lifecycle;
469
+ return lifecycle;
470
+ }
471
+
472
+ function getActiveLifecycle() {
473
+ return activeLifecycle;
474
+ }
475
+
476
+ module.exports = {
477
+ STATES,
478
+ classifyDependencyError,
479
+ createLifecycle,
480
+ errorReason,
481
+ getActiveLifecycle,
482
+ };
package/logger.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
4
+
3
5
  /**
4
6
  * @module logger
5
7
  * @description
@@ -29,12 +31,16 @@ try {
29
31
  * This allows dynamic changes at runtime (if your cache updates).
30
32
  */
31
33
  function resolveContext() {
32
- const env = cache.getValue('environment') || 'development';
34
+ const env = cache.getValue('environment') || process.env.ENVIRONMENT || process.env.NODE_ENV || 'development';
33
35
  const service = cache.getService ? cache.getService() : 'not_set_service';
34
36
  const isProd = env === 'production';
35
37
  return { env, service, isProd };
36
38
  }
37
39
 
40
+ const processStartedAt = Date.now();
41
+ const processInstanceId = process.env.INSTANCE_ID
42
+ || `${process.env.HOSTNAME || 'local'}:${process.pid}:${processStartedAt}:${crypto.randomUUID()}`;
43
+
38
44
  /**
39
45
  * Internal async writer using setImmediate to offload writes from the hot path.
40
46
  * @param {'stdout'|'stderr'} stream
@@ -93,6 +99,40 @@ function safeStringify(value) {
93
99
  );
94
100
  }
95
101
 
102
+ /**
103
+ * Emit one machine-readable lifecycle or dependency event.
104
+ *
105
+ * @param {string} event
106
+ * @param {Record<string, any>} [fields]
107
+ */
108
+ function structuredEvent(event, fields = {}) {
109
+ const { service } = resolveContext();
110
+ const severity = String(fields.severity || 'INFO').toUpperCase();
111
+ const payload = {
112
+ timestamp: new Date().toISOString(),
113
+ severity,
114
+ event,
115
+ service,
116
+ pod: process.env.HOSTNAME || null,
117
+ instanceId: processInstanceId,
118
+ deploymentVersion:
119
+ process.env.DEPLOYMENT_VERSION
120
+ || process.env.APP_VERSION
121
+ || process.env.npm_package_version
122
+ || null,
123
+ ...fields,
124
+ };
125
+ payload.timestamp = payload.timestamp || new Date().toISOString();
126
+ payload.severity = severity;
127
+ payload.event = event;
128
+ payload.service = service;
129
+ payload.instanceId = processInstanceId;
130
+ const stream = severity === 'ERROR' || severity === 'FATAL' || severity === 'WARN'
131
+ ? 'stderr'
132
+ : 'stdout';
133
+ process[stream].write(`${JSON.stringify(payload)}\n`);
134
+ }
135
+
96
136
  /**
97
137
  * Flexible debug logger supporting (msg), (obj), (msg, obj), (obj, msg).
98
138
  *
@@ -175,5 +215,6 @@ module.exports = {
175
215
  debug,
176
216
  successNote,
177
217
  criticalNote,
178
- warningNote
218
+ warningNote,
219
+ structuredEvent
179
220
  };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "b2b-platform-utils",
3
- "version": "1.1.95",
3
+ "version": "1.1.96",
4
4
  "description": "Shared utilities for Node.js microservices: errors map, local cache, logger, numbers, dates, filesystem, media optimization, paginator, slugger, crypto wrapper, sanitize HTML, sorting.",
5
5
  "type": "commonjs",
6
6
  "license": "KingSizer",
7
7
  "private": false,
8
8
  "scripts": {
9
9
  "publish-update": "npm version patch --no-git-tag-version && npm publish",
10
- "test": "node test/slugger.test.js"
10
+ "test": "node test/slugger.test.js && node test/lifecycle.test.js"
11
11
  },
12
12
  "files": [
13
13
  "*.js",