@iskra-bun/core 0.1.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -3,17 +3,146 @@ import mitt from "mitt";
3
3
 
4
4
  // src/logger/index.ts
5
5
  import pino from "pino";
6
+ import pretty from "pino-pretty";
7
+
8
+ // src/env.ts
9
+ var NODE_ENV = "NODE_ENV";
10
+ function nodeEnv() {
11
+ return process.env[NODE_ENV] || void 0;
12
+ }
13
+ function isDevelopmentEnv(env = nodeEnv()) {
14
+ return env === "development" || env === "test";
15
+ }
16
+ function isProductionEnv(env = nodeEnv()) {
17
+ return !isDevelopmentEnv(env);
18
+ }
19
+
20
+ // src/logger/index.ts
21
+ var REDACTED_KEYS = [
22
+ "password",
23
+ "pass",
24
+ "passwd",
25
+ "apiKey",
26
+ "apiSecret",
27
+ "token",
28
+ "authToken",
29
+ "accessToken",
30
+ "refreshToken",
31
+ "idToken",
32
+ "secret",
33
+ "clientSecret",
34
+ "secretKey",
35
+ "privateKey",
36
+ "authorization",
37
+ "proxyAuthorization",
38
+ "cookie",
39
+ "setCookie",
40
+ "sessionId"
41
+ ];
42
+ var REDACTED_SUFFIXES = ["password", "passwd", "secret", "token", "apikey", "secretkey", "privatekey", "accesskey"];
43
+ var normalizeKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
44
+ var SENSITIVE = new Set(REDACTED_KEYS.map(normalizeKey));
45
+ var isSensitive = (key) => {
46
+ const normalized = normalizeKey(key);
47
+ return SENSITIVE.has(normalized) || REDACTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
48
+ };
49
+ var CENSOR = "[REDACTED]";
50
+ var MAX_DEPTH = 8;
51
+ var IN_PROGRESS = /* @__PURE__ */ Symbol("in progress");
52
+ var SERIALIZED_ERROR_PROTO = Object.getPrototypeOf(pino.stdSerializers.err(new Error()));
53
+ var isPlainObject = (value) => {
54
+ const proto = Object.getPrototypeOf(value);
55
+ return proto === Object.prototype || proto === null || proto === SERIALIZED_ERROR_PROTO;
56
+ };
57
+ var SECRET_PARAM = /pass|secret|token|key|sig|auth/i;
58
+ function maskCredentials(text) {
59
+ return text.replace(/\b([a-z][\w+.-]{0,30}:\/\/[^\s:@/]{0,256}):[^\s@/]{1,256}@/gi, "$1:[REDACTED]@").replace(
60
+ /([?&])([\w.-]{1,100})=([^&\s'"#]+)/g,
61
+ (match, sep, name) => SECRET_PARAM.test(name) ? `${sep}${name}=[REDACTED]` : match
62
+ );
63
+ }
64
+ function scrubError(err, depth, done) {
65
+ const serialized = { ...pino.stdSerializers.err(err) };
66
+ if (typeof serialized.message === "string") serialized.message = maskCredentials(serialized.message);
67
+ if (typeof serialized.stack === "string") serialized.stack = maskCredentials(serialized.stack);
68
+ return scrubEntries(serialized, depth, done, true);
69
+ }
70
+ function scrub(value, depth, done, deep = false) {
71
+ if (value === null || typeof value !== "object" || depth > MAX_DEPTH) return value;
72
+ if (done.has(value)) {
73
+ const result2 = done.get(value);
74
+ return result2 === IN_PROGRESS ? "[Circular]" : result2;
75
+ }
76
+ const isError = value instanceof Error;
77
+ const toJSON = value.toJSON;
78
+ const walk = isError || Array.isArray(value) || isPlainObject(value) || typeof toJSON === "function" || deep;
79
+ if (!walk) return value;
80
+ done.set(value, IN_PROGRESS);
81
+ let result;
82
+ if (isError) {
83
+ result = scrubError(value, depth, done);
84
+ } else if (Array.isArray(value)) {
85
+ let copy;
86
+ value.forEach((item, i) => {
87
+ const scrubbed = scrub(item, depth + 1, done, deep);
88
+ if (scrubbed !== item) (copy ??= value.slice())[i] = scrubbed;
89
+ });
90
+ result = copy ?? value;
91
+ } else if (isPlainObject(value)) {
92
+ result = scrubEntries(value, depth, done, deep);
93
+ } else if (typeof toJSON === "function") {
94
+ result = scrub(toJSON.call(value), depth, done, deep);
95
+ } else {
96
+ result = scrubEntries({ ...value }, depth, done, deep);
97
+ }
98
+ done.set(value, result);
99
+ return result;
100
+ }
101
+ function scrubEntries(value, depth, done, deep = false) {
102
+ let copy;
103
+ for (const [key, item] of Object.entries(value)) {
104
+ const scrubbed = isSensitive(key) ? CENSOR : scrub(item, depth + 1, done, deep);
105
+ if (scrubbed !== item) (copy ??= { ...value })[key] = scrubbed;
106
+ }
107
+ return copy ?? (Object.getPrototypeOf(value) === SERIALIZED_ERROR_PROTO ? { ...value } : value);
108
+ }
6
109
  var createLogger = (name, level = "info") => {
7
- return pino({
110
+ const isDev = isDevelopmentEnv();
111
+ const options = {
8
112
  name,
9
113
  level,
10
- transport: {
11
- target: "pino-pretty",
12
- options: {
13
- colorize: true
114
+ formatters: {
115
+ log: (object) => scrub(object, 0, /* @__PURE__ */ new WeakMap())
116
+ },
117
+ // The formatter above already serialized errors (scrubbed): pino's own
118
+ // err serializer would take that plain object for an error again and
119
+ // rewrite its `type` as "Object".
120
+ serializers: {
121
+ err: (value) => value instanceof Error ? scrub(value, 0, /* @__PURE__ */ new WeakMap()) : value
122
+ },
123
+ hooks: {
124
+ // Messages are strings the formatter never sees: mask connection
125
+ // string passwords there too, including the message pino takes
126
+ // from an error logged on its own (`logger.error(err)`).
127
+ logMethod(args, method) {
128
+ const masked = args.map((arg) => typeof arg === "string" ? maskCredentials(arg) : arg);
129
+ if (masked[0] instanceof Error && typeof masked[1] !== "string") {
130
+ masked.splice(1, 0, maskCredentials(masked[0].message));
131
+ }
132
+ return method.apply(this, masked);
14
133
  }
134
+ },
135
+ redact: {
136
+ paths: ["config.env", "*.data"],
137
+ censor: CENSOR
15
138
  }
16
- });
139
+ };
140
+ const logger = isDev ? pino(options, pretty({ colorize: true })) : pino(options);
141
+ const child = logger.child;
142
+ logger.child = function(bindings, childOptions) {
143
+ return child.call(this, scrub(bindings, 0, /* @__PURE__ */ new WeakMap()), childOptions);
144
+ };
145
+ return logger;
17
146
  };
18
147
 
19
148
  // src/config/loader.ts
@@ -21,12 +150,17 @@ import { loadConfig } from "c12";
21
150
 
22
151
  // src/config/schema.ts
23
152
  import { z } from "zod";
153
+ var RestartBackoffSchema = z.object({
154
+ initialMs: z.number().positive().optional(),
155
+ maxMs: z.number().positive().optional(),
156
+ factor: z.number().positive().optional()
157
+ });
24
158
  var AppConfigSchema = z.object({
25
159
  name: z.string().default("IskraApp"),
26
160
  debug: z.boolean().default(false),
27
161
  logger: z.object({
28
162
  level: z.string().default("info")
29
- }).default({}),
163
+ }).passthrough().default({}),
30
164
  otel: z.object({
31
165
  enabled: z.boolean().default(true),
32
166
  endpoint: z.string().default("http://localhost:4318"),
@@ -35,16 +169,40 @@ var AppConfigSchema = z.object({
35
169
  environment: z.string().optional(),
36
170
  metricIntervalMs: z.number().default(6e4),
37
171
  resourceAttributes: z.record(z.string()).optional(),
38
- instrumentations: z.record(z.object({ enabled: z.boolean().optional() })).optional()
39
- }).optional(),
40
- processes: z.record(z.object({
41
- command: z.string(),
42
- args: z.array(z.string()).optional(),
43
- mode: z.enum(["daemon", "oneshot", "stdio"]).default("daemon"),
44
- restartOnCrash: z.boolean().default(false),
45
- env: z.record(z.string()).optional()
46
- })).optional()
47
- });
172
+ // passthrough: the instrumentations' own options (hooks, redactedQueryParams) were stripped.
173
+ instrumentations: z.record(z.object({ enabled: z.boolean().optional() }).passthrough()).optional()
174
+ }).passthrough().optional(),
175
+ shutdownSignals: z.union([z.array(z.string()), z.literal(false)]).optional(),
176
+ shutdownTimeoutMs: z.number().positive().optional(),
177
+ processes: z.record(
178
+ z.object({
179
+ command: z.string(),
180
+ args: z.array(z.string()).optional(),
181
+ mode: z.enum(["daemon", "oneshot", "stdio"]).default("daemon"),
182
+ restartOnCrash: z.boolean().default(false),
183
+ maxRestarts: z.number().int().nonnegative().optional(),
184
+ restartCooldown: z.number().nonnegative().optional(),
185
+ restartBackoff: RestartBackoffSchema.optional(),
186
+ env: z.record(z.string()).optional(),
187
+ inheritEnv: z.union([z.boolean(), z.array(z.string())]).optional(),
188
+ maxPendingStdinBytes: z.number().int().positive().optional()
189
+ }).passthrough()
190
+ ).optional(),
191
+ socket: z.object({
192
+ enabled: z.boolean(),
193
+ port: z.number().optional(),
194
+ adapter: z.enum(["bun", "socket.io"]).optional()
195
+ }).passthrough().optional(),
196
+ kv: z.object({
197
+ driver: z.enum(["memory", "redis"]),
198
+ connection: z.any().optional()
199
+ }).passthrough().optional(),
200
+ db: z.object({
201
+ driver: z.enum(["postgres", "mysql", "sqlite", "libsql"]),
202
+ url: z.string(),
203
+ authToken: z.string().optional()
204
+ }).passthrough().optional()
205
+ }).passthrough();
48
206
 
49
207
  // src/config/loader.ts
50
208
  async function loadAppConfig(cwd = process.cwd()) {
@@ -52,7 +210,14 @@ async function loadAppConfig(cwd = process.cwd()) {
52
210
  name: "app",
53
211
  configFile: "app.config",
54
212
  cwd,
55
- dotenv: true
213
+ dotenv: true,
214
+ // c12 would also merge a `.apprc` file from the working directory (a
215
+ // plain key=value file that could add `processes` to spawn or move
216
+ // `otel.endpoint`), and download and run `extends` layers from
217
+ // github:/gitlab:/https:// sources on every start. Local `extends`
218
+ // paths still work.
219
+ rcFile: false,
220
+ giget: false
56
221
  });
57
222
  const validConfig = AppConfigSchema.parse(config || {});
58
223
  return validConfig;
@@ -128,7 +293,11 @@ var ConfigError = class extends IskraError {
128
293
  };
129
294
  var DriverError = class extends IskraError {
130
295
  constructor(message, options) {
131
- super(message, { code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED, cause: options?.cause, context: options?.context });
296
+ super(message, {
297
+ code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED,
298
+ cause: options?.cause,
299
+ context: options?.context
300
+ });
132
301
  this.name = "DriverError";
133
302
  }
134
303
  };
@@ -161,6 +330,114 @@ var OTEL_MODULES = {
161
330
  function importOptional(specifier) {
162
331
  return import(specifier);
163
332
  }
333
+ var DEFAULT_ENDPOINT = "http://localhost:4318";
334
+ var HTTP_INSTRUMENTATION = "@opentelemetry/instrumentation-http";
335
+ var SECRET_QUERY_PARAMS = [
336
+ "token",
337
+ "access_token",
338
+ "refresh_token",
339
+ "id_token",
340
+ "auth_token",
341
+ "session_token",
342
+ "api_key",
343
+ "key",
344
+ "secret",
345
+ "client_secret",
346
+ "password",
347
+ "passwd",
348
+ "code",
349
+ "state",
350
+ "ticket",
351
+ "otp",
352
+ "jwt",
353
+ "signature",
354
+ "sig",
355
+ "X-Amz-Signature",
356
+ "X-Amz-Credential",
357
+ "X-Amz-Security-Token",
358
+ "AWSAccessKeyId",
359
+ "X-Goog-Signature",
360
+ "X-Goog-Credential"
361
+ ];
362
+ var normalizeParam = (name) => name.toLowerCase().replace(/[-_]/g, "");
363
+ function decodeParam(name) {
364
+ try {
365
+ return decodeURIComponent(name.replace(/\+/g, " "));
366
+ } catch {
367
+ return name;
368
+ }
369
+ }
370
+ function redactUrl(url, params = SECRET_QUERY_PARAMS) {
371
+ const start = url.indexOf("?");
372
+ if (start === -1 || params.length === 0) return url;
373
+ const names = new Set(params.map(normalizeParam));
374
+ const hash = url.indexOf("#", start);
375
+ const end = hash === -1 ? url.length : hash;
376
+ let redacted = false;
377
+ const query = url.slice(start + 1, end).split("&").map((pair) => {
378
+ const eq = pair.indexOf("=");
379
+ if (eq === -1 || !names.has(normalizeParam(decodeParam(pair.slice(0, eq))))) return pair;
380
+ redacted = true;
381
+ return `${pair.slice(0, eq)}=REDACTED`;
382
+ }).join("&");
383
+ return redacted ? url.slice(0, start + 1) + query + url.slice(end) : url;
384
+ }
385
+ var URL_ATTRIBUTES = ["url.full", "url.query", "http.url", "http.target"];
386
+ function redactSpanUrls(span, params) {
387
+ const { attributes, setAttribute } = span ?? {};
388
+ if (!attributes || typeof setAttribute !== "function") return;
389
+ for (const key of URL_ATTRIBUTES) {
390
+ const value = attributes[key];
391
+ if (typeof value !== "string") continue;
392
+ const redacted = key === "url.query" ? redactUrl(`?${value}`, params).slice(1) : redactUrl(value, params);
393
+ if (redacted !== value) setAttribute.call(span, key, redacted);
394
+ }
395
+ }
396
+ function autoInstrumentationOptions(config) {
397
+ const http = { ...config.instrumentations?.[HTTP_INSTRUMENTATION] };
398
+ const list = (value) => Array.isArray(value) ? value : void 0;
399
+ const client = list(http.redactedQueryParams) ?? SECRET_QUERY_PARAMS;
400
+ const server = list(http.redactedQueryParamsServer) ?? SECRET_QUERY_PARAMS;
401
+ const appHook = http.requestHook;
402
+ return {
403
+ "@opentelemetry/instrumentation-fs": { enabled: false },
404
+ ...config.instrumentations,
405
+ [HTTP_INSTRUMENTATION]: {
406
+ ...http,
407
+ redactedQueryParams: client,
408
+ redactedQueryParamsServer: server,
409
+ requestHook: (span, request) => {
410
+ const outgoing = typeof request?.setHeader === "function";
411
+ redactSpanUrls(span, outgoing ? client : server);
412
+ if (typeof appHook === "function") appHook(span, request);
413
+ }
414
+ }
415
+ };
416
+ }
417
+ function isPrivateHost(hostname) {
418
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
419
+ if (host.includes(":")) return host === "::1" || /^f[cd]/.test(host);
420
+ return !host.includes(".") || /\.(localhost|local|internal)$/.test(host) || /^(127|10)\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
421
+ }
422
+ function describeOtelEndpoint(config) {
423
+ try {
424
+ const url = new URL(config.endpoint || DEFAULT_ENDPOINT);
425
+ return { origin: url.origin, plaintext: url.protocol === "http:" && !isPrivateHost(url.hostname) };
426
+ } catch {
427
+ return { origin: "(invalid URL)", plaintext: false };
428
+ }
429
+ }
430
+ function createResource(resources, attributes) {
431
+ if (typeof resources?.resourceFromAttributes === "function") {
432
+ return resources.resourceFromAttributes(attributes);
433
+ }
434
+ if (typeof resources?.Resource === "function") {
435
+ return new resources.Resource(attributes);
436
+ }
437
+ throw new Error(
438
+ "[iskra/otel] Unsupported @opentelemetry/resources: it exports neither resourceFromAttributes() nor Resource"
439
+ );
440
+ }
164
441
  async function initOtel(config, appName) {
165
442
  if (sdkInstance) return;
166
443
  try {
@@ -170,7 +447,7 @@ async function initOtel(config, appName) {
170
447
  { OTLPTraceExporter },
171
448
  { OTLPMetricExporter },
172
449
  { PeriodicExportingMetricReader },
173
- { Resource },
450
+ resources,
174
451
  semconv
175
452
  ] = await Promise.all([
176
453
  importOptional(OTEL_MODULES.sdkNode),
@@ -182,17 +459,14 @@ async function initOtel(config, appName) {
182
459
  importOptional(OTEL_MODULES.semconv)
183
460
  ]);
184
461
  const serviceName = config.serviceName || appName;
185
- const endpoint = config.endpoint || "http://localhost:4318";
186
- const resource = new Resource({
187
- [semconv.ATTR_SERVICE_NAME]: serviceName,
188
- [semconv.ATTR_SERVICE_VERSION]: config.serviceVersion || "0.1.0",
189
- "deployment.environment": config.environment || process.env.NODE_ENV || "development",
462
+ const endpoint = config.endpoint || DEFAULT_ENDPOINT;
463
+ const resource = createResource(resources, {
464
+ [semconv.ATTR_SERVICE_NAME ?? "service.name"]: serviceName,
465
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: config.serviceVersion || "0.1.0",
466
+ "deployment.environment": config.environment || nodeEnv() || "development",
190
467
  ...config.resourceAttributes
191
468
  });
192
- const instrumentationOverrides = {
193
- "@opentelemetry/instrumentation-fs": { enabled: false },
194
- ...config.instrumentations
195
- };
469
+ const instrumentationOverrides = autoInstrumentationOptions(config);
196
470
  const sdk = new NodeSDK({
197
471
  resource,
198
472
  traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
@@ -205,7 +479,8 @@ async function initOtel(config, appName) {
205
479
  sdk.start();
206
480
  sdkInstance = sdk;
207
481
  } catch (err) {
208
- if (err?.code === "ERR_MODULE_NOT_FOUND" || err?.code === "MODULE_NOT_FOUND" || err?.message?.includes("Cannot find")) {
482
+ const e = err;
483
+ if (e?.code === "ERR_MODULE_NOT_FOUND" || e?.code === "MODULE_NOT_FOUND" || e?.message?.includes("Cannot find")) {
209
484
  throw new Error(
210
485
  `[iskra/otel] OpenTelemetry packages are not installed. Install them with: bun add @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/sdk-metrics @opentelemetry/resources @opentelemetry/semantic-conventions`
211
486
  );
@@ -221,12 +496,29 @@ async function shutdownOtel() {
221
496
  }
222
497
 
223
498
  // src/app.ts
499
+ var AppContext = class extends Map {
500
+ get(key) {
501
+ return super.get(key);
502
+ }
503
+ set(key, value) {
504
+ return super.set(key, value);
505
+ }
506
+ };
507
+ var DEFAULT_SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT"];
508
+ var DEFAULT_SHUTDOWN_TIMEOUT_MS = 1e4;
224
509
  var App = class {
225
510
  config;
226
511
  logger;
227
512
  events;
228
513
  drivers = [];
229
- context = /* @__PURE__ */ new Map();
514
+ /** Drivers whose start() succeeded, in start order; null until start() runs. */
515
+ startedDrivers = null;
516
+ pendingInstalls = [];
517
+ /** The shutdown in progress, shared by concurrent stop() calls. */
518
+ stopping = null;
519
+ signalHandler;
520
+ signals = [];
521
+ context = new AppContext();
230
522
  constructor(config) {
231
523
  this.events = mitt();
232
524
  this.config = config || { name: "Bootstrapping", logger: { level: "info" } };
@@ -239,13 +531,32 @@ var App = class {
239
531
  }
240
532
  if (this.config.otel && this.config.otel.enabled !== false) {
241
533
  await initOtel(this.config.otel, this.config.name);
242
- this.logger.info({ endpoint: this.config.otel.endpoint }, "OpenTelemetry initialized");
534
+ const { origin, plaintext } = describeOtelEndpoint(this.config.otel);
535
+ this.logger.info({ endpoint: origin }, "OpenTelemetry initialized");
536
+ if (plaintext) {
537
+ this.logger.warn(
538
+ { endpoint: origin },
539
+ "OpenTelemetry endpoint is plain http:// on a remote host: spans (URLs, captured headers) travel unencrypted; use https://"
540
+ );
541
+ }
243
542
  }
244
543
  this.logger.info("Initializing App...");
544
+ await Promise.all(this.pendingInstalls);
545
+ this.pendingInstalls = [];
546
+ const initialized = [];
245
547
  for (const driver of this.drivers) {
246
- if (driver.init) {
247
- this.logger.debug(`Initializing driver: ${driver.name}`);
548
+ if (!driver.init) continue;
549
+ this.logger.debug(`Initializing driver: ${driver.name}`);
550
+ initialized.push(driver);
551
+ try {
248
552
  await driver.init(this);
553
+ } catch (err) {
554
+ this.logger.error(
555
+ { err, driver: driver.name },
556
+ "Driver failed to initialize; stopping the drivers already initialized"
557
+ );
558
+ await this.rollback(initialized.reverse());
559
+ throw err;
249
560
  }
250
561
  }
251
562
  }
@@ -254,7 +565,13 @@ var App = class {
254
565
  return this;
255
566
  }
256
567
  use(plugin) {
257
- plugin.install(this);
568
+ const result = plugin.install(this);
569
+ if (result && typeof result.then === "function") {
570
+ const install = Promise.resolve(result);
571
+ install.catch(() => {
572
+ });
573
+ this.pendingInstalls.push(install);
574
+ }
258
575
  return this;
259
576
  }
260
577
  on(event, handler) {
@@ -276,39 +593,164 @@ var App = class {
276
593
  emit(event, payload) {
277
594
  this.events.emit(event, payload);
278
595
  }
596
+ /**
597
+ * Starts drivers one at a time, in registration order, so a driver can rely
598
+ * on the ones registered before it (e.g. the DB before the web server). If
599
+ * one fails, every driver is stopped in reverse order (all were initialized
600
+ * and may hold connections, including the failing one), OpenTelemetry is
601
+ * shut down and the error is rethrown, instead of leaving ports bound and
602
+ * children running.
603
+ */
279
604
  async start() {
280
605
  await this.init();
606
+ if (!nodeEnv()) {
607
+ this.logger.warn(
608
+ "NODE_ENV is not set: production defaults apply (Secure cookies, no sample secrets, JSON logs). Set NODE_ENV=development for local development."
609
+ );
610
+ }
281
611
  this.logger.info("Starting drivers...");
282
- await Promise.all(this.drivers.map((d) => d.start ? d.start() : Promise.resolve()));
612
+ const started = [];
613
+ for (const driver of this.drivers) {
614
+ try {
615
+ if (driver.start) await driver.start();
616
+ started.push(driver);
617
+ } catch (err) {
618
+ this.logger.error({ err, driver: driver.name }, "Driver failed to start; stopping every driver");
619
+ await this.rollback([...this.drivers].reverse());
620
+ throw err;
621
+ }
622
+ }
623
+ this.startedDrivers = started;
624
+ this.installSignalHandlers();
283
625
  this.logger.info("App started successfully \u{1F680}");
284
626
  }
285
- async stop() {
627
+ /**
628
+ * Stops drivers in reverse start order (the web server before the DB it
629
+ * uses), continuing past failures, and always flushes OpenTelemetry.
630
+ *
631
+ * A call made while a stop is in progress (e.g. an app's own signal
632
+ * handler next to the App's) gets that same stop: it resolves only once
633
+ * every driver has stopped, instead of right away with nothing left to do.
634
+ */
635
+ stop() {
636
+ this.stopping ??= this.stopOnce().finally(() => {
637
+ this.stopping = null;
638
+ });
639
+ return this.stopping;
640
+ }
641
+ async stopOnce() {
286
642
  this.logger.info("Stopping app...");
287
- const results = await Promise.allSettled(this.drivers.map((d) => d.stop ? d.stop() : Promise.resolve()));
288
- const failures = results.filter((r) => r.status === "rejected");
643
+ const toStop = [...this.startedDrivers ?? this.drivers].reverse();
644
+ this.startedDrivers = [];
645
+ let failures;
646
+ try {
647
+ failures = await this.stopDrivers(toStop);
648
+ } finally {
649
+ await shutdownOtel();
650
+ this.removeSignalHandlers();
651
+ }
289
652
  if (failures.length > 0) {
290
653
  this.logger.error({ failures }, "Some drivers failed to stop");
291
654
  throw new LifecycleError("Some drivers failed to stop", {
292
655
  failures,
293
- context: { driverCount: this.drivers.length, failedCount: failures.length }
656
+ context: { driverCount: toStop.length, failedCount: failures.length }
294
657
  });
295
658
  }
296
- await shutdownOtel();
297
659
  this.logger.info("App stopped.");
298
660
  }
661
+ /**
662
+ * Stops `drivers` after a failed init() or start() and shuts down
663
+ * OpenTelemetry. Failures to stop are logged, not thrown: the caller
664
+ * rethrows the original error. A later stop() stops nothing more.
665
+ */
666
+ async rollback(drivers) {
667
+ this.startedDrivers = [];
668
+ await this.stopDrivers(drivers);
669
+ try {
670
+ await shutdownOtel();
671
+ } catch (err) {
672
+ this.logger.error({ err }, "OpenTelemetry failed to shut down");
673
+ }
674
+ }
675
+ async stopDrivers(drivers) {
676
+ const failures = [];
677
+ for (const driver of drivers) {
678
+ try {
679
+ if (driver.stop) await driver.stop();
680
+ } catch (reason) {
681
+ this.logger.error({ err: reason, driver: driver.name }, "Driver failed to stop");
682
+ failures.push({ status: "rejected", reason });
683
+ }
684
+ }
685
+ return failures;
686
+ }
687
+ /**
688
+ * Stops the app gracefully on SIGTERM/SIGINT (configurable with
689
+ * `shutdownSignals`, off by default under NODE_ENV=test). Once a listener
690
+ * exists the runtime no longer exits on its own, so the handler exits with
691
+ * 0 after a clean stop, or 1 on failure / after `shutdownTimeoutMs`. A
692
+ * second signal forces exit.
693
+ */
694
+ installSignalHandlers() {
695
+ const configured = this.config.shutdownSignals;
696
+ const signals = configured === false ? [] : configured ?? (nodeEnv() === "test" ? [] : DEFAULT_SHUTDOWN_SIGNALS);
697
+ if (signals.length === 0 || this.signalHandler) return;
698
+ const timeoutMs = this.config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
699
+ let shuttingDown = false;
700
+ this.signalHandler = (signal) => {
701
+ if (shuttingDown) {
702
+ this.logger.warn({ signal }, "Second shutdown signal; exiting immediately");
703
+ process.exit(1);
704
+ }
705
+ shuttingDown = true;
706
+ this.logger.info({ signal }, "Shutdown signal received; stopping app");
707
+ const timer = setTimeout(() => {
708
+ this.logger.error({ timeoutMs }, "Graceful shutdown timed out; exiting");
709
+ process.exit(1);
710
+ }, timeoutMs);
711
+ this.stop().then(
712
+ () => {
713
+ clearTimeout(timer);
714
+ process.exit(0);
715
+ },
716
+ (err) => {
717
+ clearTimeout(timer);
718
+ this.logger.error({ err }, "Graceful shutdown failed");
719
+ process.exit(1);
720
+ }
721
+ );
722
+ };
723
+ this.signals = signals;
724
+ for (const signal of this.signals) process.on(signal, this.signalHandler);
725
+ }
726
+ removeSignalHandlers() {
727
+ if (!this.signalHandler) return;
728
+ for (const signal of this.signals) process.off(signal, this.signalHandler);
729
+ this.signalHandler = void 0;
730
+ this.signals = [];
731
+ }
299
732
  };
300
733
  export {
301
734
  App,
302
735
  AppConfigSchema,
736
+ AppContext,
303
737
  ConfigError,
304
738
  DriverError,
305
739
  ErrorCodes,
306
740
  IskraError,
307
741
  LifecycleError,
308
742
  PluginError,
743
+ SECRET_QUERY_PARAMS,
744
+ autoInstrumentationOptions,
309
745
  createLogger,
746
+ createResource,
747
+ describeOtelEndpoint,
310
748
  initOtel,
749
+ isDevelopmentEnv,
750
+ isProductionEnv,
311
751
  loadAppConfig,
752
+ nodeEnv,
753
+ redactUrl,
312
754
  shutdownOtel
313
755
  };
314
756
  //# sourceMappingURL=index.js.map