@cldmv/slothlet 2.7.0 → 2.8.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/README.md CHANGED
@@ -464,6 +464,7 @@ Creates and loads an API instance with the specified configuration.
464
464
  | `apiDepth` | `number` | `Infinity` | Directory traversal depth control - `0` for root only, `Infinity` for all levels |
465
465
  | `debug` | `boolean` | `false` | Enable verbose logging. Can also be set via `--slothletdebug` command line flag or `SLOTHLET_DEBUG=true` environment variable |
466
466
  | `api_mode` | `string` | `"auto"` | API structure behavior when root-level default functions exist:<br/>• `"auto"`: Automatically detects if root has default function export and creates callable API<br/>• `"function"`: Forces API to be callable (use when you have root-level default function exports)<br/>• `"object"`: Forces API to be object-only (use when you want object interface regardless of exports) |
467
+ | `allowApiOverwrite` | `boolean` | `true` | Controls whether `addApi()` can overwrite existing API endpoints:<br/>• `true`: Allow overwrites (default, backwards compatible)<br/>• `false`: Prevent overwrites - logs warning and skips when attempting to overwrite existing endpoints<br/>Applies to both function and object overwrites at the final key of the API path |
467
468
  | `context` | `object` | `{}` | Context data object injected into live-binding `context` reference. Available to all loaded modules via `import { context } from "@cldmv/slothlet/runtime"` |
468
469
  | `reference` | `object` | `{}` | Reference object merged into the API root level. Properties not conflicting with loaded modules are added directly to the API |
469
470
  | `sanitize` | `object` | `{}` | **🔧 NEW**: Advanced filename-to-API transformation control. Options: `lowerFirst` (boolean), `preserveAllUpper` (boolean), `preserveAllLower` (boolean), `rules` object with `leave` (exact case-sensitive), `leaveInsensitive` (case-insensitive), `upper`/`lower` arrays. Supports exact matches, glob patterns (`*json*`, `http*`), and boundary patterns (`**url**` for surrounded matches only) |
@@ -522,10 +523,121 @@ Returns true if the API is loaded.
522
523
 
523
524
  #### `slothlet.shutdown()` ⇒ `Promise<void>`
524
525
 
525
- Gracefully shuts down the API and cleans up resources.
526
+ Gracefully shuts down the API and performs comprehensive resource cleanup to prevent hanging processes.
527
+
528
+ **Cleanup includes:**
529
+
530
+ - Hook manager state and registered hooks
531
+ - AsyncLocalStorage context and bindings
532
+ - EventEmitter listeners and AsyncResource instances (including third-party libraries)
533
+ - Instance data and runtime coordination
526
534
 
527
535
  **Returns:** `Promise<void>` - Resolves when shutdown is complete
528
536
 
537
+ > [!IMPORTANT]
538
+ > **🛡️ Process Cleanup**: The shutdown method now performs comprehensive cleanup of all EventEmitter listeners created after slothlet loads, including those from third-party libraries like pg-pool. This prevents hanging AsyncResource instances that could prevent your Node.js process from exiting cleanly.
539
+
540
+ #### `api.addApi(apiPath, folderPath)` ⇒ `Promise<void>` ⭐ NEW
541
+
542
+ Dynamically extend your API at runtime by loading additional modules and merging them into a specified path.
543
+
544
+ **Parameters:**
545
+
546
+ | Param | Type | Description |
547
+ | ------------ | -------- | ------------------------------------------------------------------- |
548
+ | `apiPath` | `string` | Dotted path where modules will be added (e.g., `"runtime.plugins"`) |
549
+ | `folderPath` | `string` | Path to folder containing modules to load (relative or absolute) |
550
+
551
+ **Returns:** `Promise<void>` - Resolves when the API extension is complete
552
+
553
+ **Features:**
554
+
555
+ - ✅ **Dynamic Loading**: Add modules after initial API creation
556
+ - ✅ **Path Creation**: Automatically creates intermediate objects for nested paths
557
+ - ✅ **Smart Merging**: Merges into existing objects or creates new namespaces
558
+ - ✅ **Mode Respect**: Uses same loading mode (lazy/eager) as parent API
559
+ - ✅ **Live Binding Updates**: Automatically updates `self`, `context`, and `reference`
560
+ - ✅ **Function Support**: Can traverse through functions (slothlet's function.property pattern)
561
+ - ✅ **Validation**: Prevents extension through primitives, validates path format
562
+ - ✅ **Overwrite Protection**: Optional `allowApiOverwrite` config prevents accidental endpoint overwrites
563
+
564
+ **Configuration:**
565
+
566
+ The `allowApiOverwrite` option controls whether `addApi` can overwrite existing endpoints:
567
+
568
+ ```javascript
569
+ // Default behavior - allows overwrites (backwards compatible)
570
+ const api = await slothlet({
571
+ dir: "./api",
572
+ allowApiOverwrite: true // default
573
+ });
574
+ await api.addApi("tools", "./new-tools"); // Overwrites existing api.tools
575
+
576
+ // Protected mode - prevents overwrites
577
+ const api = await slothlet({
578
+ dir: "./api",
579
+ allowApiOverwrite: false // protection enabled
580
+ });
581
+ await api.addApi("tools", "./new-tools"); // Logs warning and skips
582
+ // Console: "[slothlet] Skipping addApi: API path "tools" final key "tools" already exists..."
583
+ ```
584
+
585
+ **Usage Examples:**
586
+
587
+ ```javascript
588
+ const api = await slothlet({ dir: "./api" });
589
+
590
+ // Add modules to nested path
591
+ await api.addApi("runtime.plugins", "./plugins");
592
+ api.runtime.plugins.myPlugin(); // New modules accessible
593
+
594
+ // Add to root level
595
+ await api.addApi("utilities", "./utils");
596
+ api.utilities.helperFunc(); // Root-level addition
597
+
598
+ // Deep nesting (creates intermediate objects)
599
+ await api.addApi("services.external.github", "./integrations/github");
600
+ api.services.external.github.getUser(); // Deep path created
601
+
602
+ // Merge into existing namespace
603
+ await api.addApi("math", "./advanced-math"); // Merges with existing api.math
604
+ api.math.add(2, 3); // Original functions preserved
605
+ api.math.advancedCalc(); // New functions added
606
+ ```
607
+
608
+ **Path Validation:**
609
+
610
+ ```javascript
611
+ // ❌ Invalid paths throw errors
612
+ await api.addApi("", "./modules"); // Empty string
613
+ await api.addApi(".path", "./modules"); // Leading dot
614
+ await api.addApi("path.", "./modules"); // Trailing dot
615
+ await api.addApi("path..name", "./modules"); // Consecutive dots
616
+
617
+ // ✅ Valid paths
618
+ await api.addApi("simple", "./modules"); // Single segment
619
+ await api.addApi("nested.path", "./modules"); // Dotted path
620
+ await api.addApi("very.deep.nested", "./modules"); // Multi-level
621
+ ```
622
+
623
+ **Type Safety:**
624
+
625
+ ```javascript
626
+ // ✅ Can extend through objects and functions
627
+ api.logger = { info: () => {} };
628
+ await api.addApi("logger.plugins", "./logger-plugins"); // Works - object
629
+
630
+ api.handler = () => "handler";
631
+ await api.addApi("handler.middleware", "./middleware"); // Works - function
632
+
633
+ // ❌ Cannot extend through primitives
634
+ api.config.timeout = 5000;
635
+ await api.addApi("config.timeout.advanced", "./modules"); // Throws error
636
+ ```
637
+
638
+ > [!WARNING]
639
+ > **⚠️ Concurrency Limitation:** The `addApi` method is **not thread-safe**. Concurrent calls to `addApi` with overlapping paths may result in race conditions and inconsistent API state. Ensure calls are properly sequenced using `await` or other synchronization mechanisms. Do not call `addApi` from multiple threads/workers simultaneously.
640
+
529
641
  > [!NOTE]
530
642
  > **📚 For detailed API documentation with comprehensive parameter descriptions, method signatures, and examples, see [docs/API.md](https://github.com/CLDMV/slothlet/blob/HEAD/docs/API.md)**
531
643
 
@@ -649,6 +761,7 @@ console.log("TCP server started with context preservation");
649
761
  - ✅ **Nested Events**: Works with any depth of EventEmitter nesting (server → socket → custom emitters)
650
762
  - ✅ **Universal Support**: All EventEmitter methods (`on`, `once`, `addListener`) are automatically context-aware
651
763
  - ✅ **Production Ready**: Uses Node.js AsyncResource patterns for reliable context propagation
764
+ - ✅ **Clean Shutdown**: Automatically cleans up all AsyncResource instances during shutdown to prevent hanging processes
652
765
  - ✅ **Zero Overhead**: Only wraps listeners when context is active, minimal performance impact
653
766
 
654
767
  > [!TIP]
@@ -26,6 +26,17 @@ import { AsyncLocalStorage } from "node:async_hooks";
26
26
  const defaultALS = new AsyncLocalStorage();
27
27
 
28
28
 
29
+ let originalMethods = null;
30
+
31
+
32
+ const globalResourceSet = new Set();
33
+
34
+
35
+
36
+ const globalListenerTracker = new WeakMap();
37
+ const allPatchedListeners = new Set();
38
+
39
+
29
40
  export function enableAlsForEventEmitters(als = defaultALS) {
30
41
 
31
42
  const kPatched = Symbol.for("slothlet.als.patched");
@@ -52,6 +63,9 @@ export function enableAlsForEventEmitters(als = defaultALS) {
52
63
  const resource = new AsyncResource("slothlet-als-listener");
53
64
 
54
65
 
66
+ globalResourceSet.add(resource);
67
+
68
+
55
69
  const runtime_wrappedListener = function (...args) {
56
70
  return resource.runInAsyncScope(
57
71
  () => {
@@ -62,6 +76,9 @@ export function enableAlsForEventEmitters(als = defaultALS) {
62
76
  );
63
77
  };
64
78
 
79
+
80
+ runtime_wrappedListener._slothletResource = resource;
81
+
65
82
  return runtime_wrappedListener;
66
83
  }
67
84
 
@@ -81,6 +98,22 @@ export function enableAlsForEventEmitters(als = defaultALS) {
81
98
  proto[addFnName] = function (event, listener) {
82
99
  const map = runtime_ensureMap(this);
83
100
  const wrapped = runtime_wrapListener(listener);
101
+
102
+
103
+
104
+ if (!globalListenerTracker.has(this)) {
105
+ globalListenerTracker.set(this, new Set());
106
+ }
107
+ const listenerInfo = {
108
+ emitter: this,
109
+ event,
110
+ originalListener: listener,
111
+ wrappedListener: wrapped,
112
+ addMethod: addFnName
113
+ };
114
+ globalListenerTracker.get(this).add(listenerInfo);
115
+ allPatchedListeners.add(listenerInfo);
116
+
84
117
  if (wrapped !== listener) map.set(listener, wrapped);
85
118
  return orig.call(this, event, wrapped);
86
119
  };
@@ -99,6 +132,30 @@ export function enableAlsForEventEmitters(als = defaultALS) {
99
132
  const runtime_removeWrapper = function (event, listener) {
100
133
  const map = runtime_ensureMap(this);
101
134
  const wrapped = map.get(listener) || listener;
135
+
136
+
137
+ if (globalListenerTracker.has(this)) {
138
+ const emitterListeners = globalListenerTracker.get(this);
139
+ for (const info of emitterListeners) {
140
+ if (info.originalListener === listener || info.wrappedListener === wrapped) {
141
+ emitterListeners.delete(info);
142
+ allPatchedListeners.delete(info);
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+
149
+ if (wrapped && wrapped._slothletResource) {
150
+ const resource = wrapped._slothletResource;
151
+ globalResourceSet.delete(resource);
152
+ try {
153
+ resource.emitDestroy();
154
+ } catch (err) {
155
+
156
+ }
157
+ }
158
+
102
159
  map.delete(listener);
103
160
  return method.call(this, event, wrapped);
104
161
  };
@@ -117,4 +174,84 @@ export function enableAlsForEventEmitters(als = defaultALS) {
117
174
  if (this[kMap]) this[kMap] = new WeakMap();
118
175
  return res;
119
176
  };
177
+
178
+
179
+ if (!originalMethods) {
180
+ originalMethods = {
181
+ on: origOn,
182
+ once: origOnce,
183
+ addListener: origAdd,
184
+ prependListener: origPre,
185
+ prependOnceListener: origPreO,
186
+ off: origOff,
187
+ removeListener: origRem,
188
+ removeAllListeners: origRemoveAll
189
+ };
190
+ }
191
+ }
192
+
193
+
194
+
195
+ export function cleanupAllSlothletListeners() {
196
+ let cleanedCount = 0;
197
+ let errorCount = 0;
198
+
199
+
200
+ for (const listenerInfo of allPatchedListeners) {
201
+ try {
202
+ const { emitter, event, wrappedListener } = listenerInfo;
203
+ if (emitter && typeof emitter.removeListener === "function") {
204
+ emitter.removeListener(event, wrappedListener);
205
+ cleanedCount++;
206
+ }
207
+ } catch (err) {
208
+ errorCount++;
209
+
210
+ }
211
+ }
212
+
213
+
214
+ allPatchedListeners.clear();
215
+
216
+
217
+ if (process.env.NODE_ENV === "development" || process.env.SLOTHLET_DEBUG) {
218
+ console.log(`[slothlet] Cleaned up ${cleanedCount} listeners (${errorCount} errors)`);
219
+ }
220
+ }
221
+
222
+ export function disableAlsForEventEmitters() {
223
+ const kPatched = Symbol.for("slothlet.als.patched");
224
+
225
+ if (!EventEmitter.prototype[kPatched] || !originalMethods) return;
226
+
227
+
228
+ cleanupAllSlothletListeners();
229
+
230
+
231
+ for (const resource of globalResourceSet) {
232
+ try {
233
+ resource.emitDestroy();
234
+ } catch (err) {
235
+
236
+ console.warn("[slothlet] AsyncResource cleanup warning:", err.message);
237
+ }
238
+ }
239
+ globalResourceSet.clear();
240
+
241
+
242
+ const proto = EventEmitter.prototype;
243
+ proto.on = originalMethods.on;
244
+ proto.once = originalMethods.once;
245
+ proto.addListener = originalMethods.addListener;
246
+ if (originalMethods.prependListener) proto.prependListener = originalMethods.prependListener;
247
+ if (originalMethods.prependOnceListener) proto.prependOnceListener = originalMethods.prependOnceListener;
248
+ if (originalMethods.off) proto.off = originalMethods.off;
249
+ proto.removeListener = originalMethods.removeListener;
250
+ proto.removeAllListeners = originalMethods.removeAllListeners;
251
+
252
+
253
+ delete EventEmitter.prototype[kPatched];
254
+
255
+
256
+ originalMethods = null;
120
257
  }
@@ -53,6 +53,14 @@ export class HookManager {
53
53
  }
54
54
 
55
55
 
56
+ cleanup() {
57
+ this.hooks.clear();
58
+ this.reportedErrors = new WeakSet();
59
+ this.registrationOrder = 0;
60
+ this.enabled = false;
61
+ }
62
+
63
+
56
64
  off(nameOrPattern) {
57
65
 
58
66
  if (this.hooks.has(nameOrPattern)) {
package/dist/slothlet.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  buildCategoryDecisions
32
32
  } from "@cldmv/slothlet/helpers/api_builder";
33
33
  import { updateInstanceData, cleanupInstance } from "./lib/helpers/instance-manager.mjs";
34
+ import { disableAlsForEventEmitters, cleanupAllSlothletListeners } from "./lib/helpers/als-eventemitter.mjs";
34
35
  import { HookManager } from "./lib/helpers/hooks.mjs";
35
36
 
36
37
 
@@ -139,7 +140,7 @@ const slothletObject = {
139
140
  reference: {},
140
141
  mode: "singleton",
141
142
  loaded: false,
142
- config: { lazy: false, apiDepth: Infinity, debug: DEBUG, dir: null, sanitize: null },
143
+ config: { lazy: false, apiDepth: Infinity, debug: DEBUG, dir: null, sanitize: null, allowApiOverwrite: true },
143
144
  _dispose: null,
144
145
  _boundAPIShutdown: null,
145
146
  instanceId: null,
@@ -993,9 +994,12 @@ const slothletObject = {
993
994
 
994
995
 
995
996
 
997
+
998
+
999
+ const instance = this;
996
1000
  this.safeDefine(boundApi, "describe", function (showAll = false) {
997
1001
 
998
- if (this.config && this.config.lazy) {
1002
+ if (instance.config && instance.config.lazy) {
999
1003
  if (!showAll) {
1000
1004
  return Reflect.ownKeys(boundApi);
1001
1005
  }
@@ -1051,17 +1055,36 @@ const slothletObject = {
1051
1055
  } else {
1052
1056
  this._boundAPIShutdown = null;
1053
1057
  }
1058
+
1059
+
1060
+
1061
+
1062
+ const hasUserDefinedShutdown = this._boundAPIShutdown !== null;
1063
+
1054
1064
  const shutdownDesc = Object.getOwnPropertyDescriptor(boundApi, "shutdown");
1055
1065
  if (!shutdownDesc || shutdownDesc.configurable) {
1056
1066
  Object.defineProperty(boundApi, "shutdown", {
1057
1067
  value: this.shutdown.bind(this),
1058
1068
  writable: true,
1059
1069
  configurable: true,
1060
- enumerable: true
1070
+ enumerable: hasUserDefinedShutdown
1061
1071
  });
1062
1072
  } else if (this.config && this.config.debug) {
1063
1073
  console.warn("Could not redefine boundApi.shutdown: not configurable");
1064
1074
  }
1075
+
1076
+
1077
+ const addApiDesc = Object.getOwnPropertyDescriptor(boundApi, "addApi");
1078
+ if (!addApiDesc || addApiDesc.configurable) {
1079
+ Object.defineProperty(boundApi, "addApi", {
1080
+ value: this.addApi.bind(this),
1081
+ writable: true,
1082
+ configurable: true,
1083
+ enumerable: false
1084
+ });
1085
+ } else if (this.config && this.config.debug) {
1086
+ console.warn("Could not redefine boundApi.addApi: not configurable");
1087
+ }
1065
1088
 
1066
1089
 
1067
1090
 
@@ -1073,21 +1096,21 @@ const slothletObject = {
1073
1096
  },
1074
1097
 
1075
1098
 
1076
- safeDefine(obj, key, value) {
1099
+ safeDefine(obj, key, value, enumerable = false) {
1077
1100
  const desc = Object.getOwnPropertyDescriptor(obj, key);
1078
1101
  if (!desc) {
1079
1102
  Object.defineProperty(obj, key, {
1080
1103
  value,
1081
1104
  writable: true,
1082
1105
  configurable: true,
1083
- enumerable: true
1106
+ enumerable
1084
1107
  });
1085
1108
  } else if (desc.configurable) {
1086
1109
  Object.defineProperty(obj, key, {
1087
1110
  value,
1088
1111
  writable: true,
1089
1112
  configurable: true,
1090
- enumerable: true
1113
+ enumerable
1091
1114
  });
1092
1115
  } else if (this.config && this.config.debug) {
1093
1116
  console.warn(`Could not redefine boundApi.${key}: not configurable`);
@@ -1110,6 +1133,220 @@ const slothletObject = {
1110
1133
  },
1111
1134
 
1112
1135
 
1136
+ async addApi(apiPath, folderPath) {
1137
+ if (!this.loaded) {
1138
+ throw new Error("[slothlet] Cannot add API: API not loaded. Call create() or load() first.");
1139
+ }
1140
+
1141
+
1142
+ if (typeof apiPath !== "string") {
1143
+ throw new TypeError("[slothlet] addApi: 'apiPath' must be a string.");
1144
+ }
1145
+ const normalizedApiPath = apiPath.trim();
1146
+ if (normalizedApiPath === "") {
1147
+ throw new TypeError("[slothlet] addApi: 'apiPath' must be a non-empty, non-whitespace string.");
1148
+ }
1149
+ const pathParts = normalizedApiPath.split(".");
1150
+ if (pathParts.some((part) => part === "")) {
1151
+ throw new Error(`[slothlet] addApi: 'apiPath' must not contain empty segments. Received: "${normalizedApiPath}"`);
1152
+ }
1153
+
1154
+
1155
+ if (typeof folderPath !== "string") {
1156
+ throw new TypeError("[slothlet] addApi: 'folderPath' must be a string.");
1157
+ }
1158
+
1159
+
1160
+ let resolvedFolderPath = folderPath;
1161
+ if (!path.isAbsolute(folderPath)) {
1162
+ resolvedFolderPath = resolvePathFromCaller(folderPath);
1163
+ }
1164
+
1165
+
1166
+ let stats;
1167
+ try {
1168
+ stats = await fs.stat(resolvedFolderPath);
1169
+ } catch (error) {
1170
+ throw new Error(`[slothlet] addApi: Cannot access folder: ${resolvedFolderPath} - ${error.message}`);
1171
+ }
1172
+ if (!stats.isDirectory()) {
1173
+ throw new Error(`[slothlet] addApi: Path is not a directory: ${resolvedFolderPath}`);
1174
+ }
1175
+
1176
+ if (this.config.debug) {
1177
+ console.log(`[DEBUG] addApi: Loading modules from ${resolvedFolderPath} to path: ${normalizedApiPath}`);
1178
+ }
1179
+
1180
+
1181
+ let newModules;
1182
+ if (this.config.lazy) {
1183
+
1184
+ newModules = await this.modes.lazy.create.call(this, resolvedFolderPath, this.config.apiDepth || Infinity, 0);
1185
+ } else {
1186
+
1187
+ newModules = await this.modes.eager.create.call(this, resolvedFolderPath, this.config.apiDepth || Infinity, 0);
1188
+ }
1189
+
1190
+ if (this.config.debug) {
1191
+ if (newModules && typeof newModules === "object") {
1192
+ console.log(`[DEBUG] addApi: Loaded modules:`, Object.keys(newModules));
1193
+ } else {
1194
+ console.log(
1195
+ `[DEBUG] addApi: Loaded modules (non-object):`,
1196
+ typeof newModules === "function" ? `[Function: ${newModules.name || "anonymous"}]` : newModules
1197
+ );
1198
+ }
1199
+ }
1200
+
1201
+
1202
+ let currentTarget = this.api;
1203
+ let currentBoundTarget = this.boundapi;
1204
+
1205
+ for (let i = 0; i < pathParts.length - 1; i++) {
1206
+ const part = pathParts[i];
1207
+ const key = this._toapiPathKey(part);
1208
+
1209
+
1210
+
1211
+
1212
+ if (Object.prototype.hasOwnProperty.call(currentTarget, key)) {
1213
+ const existing = currentTarget[key];
1214
+ if (existing === null || (typeof existing !== "object" && typeof existing !== "function")) {
1215
+ throw new Error(
1216
+ `[slothlet] Cannot extend API path "${normalizedApiPath}" through segment "${part}": ` +
1217
+ `existing value is type "${typeof existing}", cannot add properties.`
1218
+ );
1219
+ }
1220
+
1221
+
1222
+ } else {
1223
+ currentTarget[key] = {};
1224
+ }
1225
+ if (Object.prototype.hasOwnProperty.call(currentBoundTarget, key)) {
1226
+ const existingBound = currentBoundTarget[key];
1227
+ if (existingBound === null || (typeof existingBound !== "object" && typeof existingBound !== "function")) {
1228
+ throw new Error(
1229
+ `[slothlet] Cannot extend bound API path "${normalizedApiPath}" through segment "${part}": ` +
1230
+ `existing value is type "${typeof existingBound}", cannot add properties.`
1231
+ );
1232
+ }
1233
+
1234
+ } else {
1235
+ currentBoundTarget[key] = {};
1236
+ }
1237
+
1238
+
1239
+ currentTarget = currentTarget[key];
1240
+ currentBoundTarget = currentBoundTarget[key];
1241
+ }
1242
+
1243
+
1244
+ const finalKey = this._toapiPathKey(pathParts[pathParts.length - 1]);
1245
+
1246
+
1247
+ if (typeof newModules === "function") {
1248
+
1249
+
1250
+ if (Object.prototype.hasOwnProperty.call(currentTarget, finalKey)) {
1251
+ const existing = currentTarget[finalKey];
1252
+
1253
+
1254
+ if (this.config.allowApiOverwrite === false) {
1255
+ console.warn(
1256
+ `[slothlet] Skipping addApi: API path "${normalizedApiPath}" final key "${finalKey}" ` +
1257
+ `already exists (type: "${typeof existing}"). Set allowApiOverwrite: true to allow overwrites.`
1258
+ );
1259
+ return;
1260
+ }
1261
+
1262
+
1263
+ if (existing !== null && typeof existing !== "function") {
1264
+ console.warn(
1265
+ `[slothlet] Overwriting existing non-function value at API path "${normalizedApiPath}" ` +
1266
+ `final key "${finalKey}" with a function. Previous type: "${typeof existing}".`
1267
+ );
1268
+ } else if (typeof existing === "function") {
1269
+
1270
+ console.warn(
1271
+ `[slothlet] Overwriting existing function at API path "${normalizedApiPath}" ` + `final key "${finalKey}" with a new function.`
1272
+ );
1273
+ }
1274
+ }
1275
+ currentTarget[finalKey] = newModules;
1276
+ currentBoundTarget[finalKey] = newModules;
1277
+ } else if (typeof newModules === "object" && newModules !== null) {
1278
+
1279
+ if (Object.prototype.hasOwnProperty.call(currentTarget, finalKey)) {
1280
+ const existing = currentTarget[finalKey];
1281
+
1282
+
1283
+ if (this.config.allowApiOverwrite === false && existing !== undefined && existing !== null) {
1284
+
1285
+ const hasContent = typeof existing === "object" ? Object.keys(existing).length > 0 : true;
1286
+ if (hasContent) {
1287
+ console.warn(
1288
+ `[slothlet] Skipping addApi merge: API path "${normalizedApiPath}" final key "${finalKey}" ` +
1289
+ `already exists with content (type: "${typeof existing}"). Set allowApiOverwrite: true to allow merging.`
1290
+ );
1291
+ return;
1292
+ }
1293
+ }
1294
+
1295
+ if (existing !== null && typeof existing !== "object" && typeof existing !== "function") {
1296
+ throw new Error(
1297
+ `[slothlet] Cannot merge API at "${normalizedApiPath}": ` +
1298
+ `existing value at final key "${finalKey}" is type "${typeof existing}", cannot merge into primitives.`
1299
+ );
1300
+ }
1301
+ }
1302
+ if (Object.prototype.hasOwnProperty.call(currentBoundTarget, finalKey)) {
1303
+ const existingBound = currentBoundTarget[finalKey];
1304
+ if (existingBound !== null && typeof existingBound !== "object" && typeof existingBound !== "function") {
1305
+ throw new Error(
1306
+ `[slothlet] Cannot merge bound API at "${normalizedApiPath}": ` +
1307
+ `existing value at final key "${finalKey}" is type "${typeof existingBound}", cannot merge into primitives.`
1308
+ );
1309
+ }
1310
+ }
1311
+
1312
+
1313
+ if (!currentTarget[finalKey]) {
1314
+ currentTarget[finalKey] = {};
1315
+ }
1316
+ if (!currentBoundTarget[finalKey]) {
1317
+ currentBoundTarget[finalKey] = {};
1318
+ }
1319
+
1320
+
1321
+
1322
+
1323
+
1324
+
1325
+ Object.assign(currentTarget[finalKey], newModules);
1326
+ Object.assign(currentBoundTarget[finalKey], newModules);
1327
+ } else if (newModules === null || newModules === undefined) {
1328
+
1329
+ const receivedType = newModules === null ? "null" : "undefined";
1330
+ console.warn(
1331
+ `[slothlet] addApi: No modules loaded from folder at API path "${normalizedApiPath}". ` +
1332
+ `Loaded modules resulted in ${receivedType}. Check that the folder contains valid module files.`
1333
+ );
1334
+ } else {
1335
+
1336
+
1337
+ currentTarget[finalKey] = newModules;
1338
+ currentBoundTarget[finalKey] = newModules;
1339
+ }
1340
+
1341
+
1342
+ this.updateBindings(this.context, this.reference, this.boundapi);
1343
+
1344
+ if (this.config.debug) {
1345
+ console.log(`[DEBUG] addApi: Successfully added modules at ${normalizedApiPath}`);
1346
+ }
1347
+ },
1348
+
1349
+
1113
1350
  async shutdown() {
1114
1351
 
1115
1352
 
@@ -1152,12 +1389,30 @@ const slothletObject = {
1152
1389
  this._boundAPIShutdown = null;
1153
1390
 
1154
1391
 
1392
+ if (this.hookManager) {
1393
+ this.hookManager.cleanup();
1394
+ this.hookManager = null;
1395
+ }
1396
+
1397
+
1155
1398
 
1156
1399
 
1157
1400
  if (this.instanceId) {
1158
1401
  await cleanupInstance(this.instanceId);
1159
1402
  }
1160
1403
 
1404
+
1405
+
1406
+ try {
1407
+
1408
+ cleanupAllSlothletListeners();
1409
+
1410
+ disableAlsForEventEmitters();
1411
+ } catch (cleanupError) {
1412
+
1413
+ console.warn("[slothlet] Warning: EventEmitter cleanup failed:", cleanupError.message);
1414
+ }
1415
+
1161
1416
  if (apiError || internalError) throw apiError || internalError;
1162
1417
  }
1163
1418
  } finally {
@@ -1197,6 +1452,18 @@ export function mutateLiveBindingFunction(target, source) {
1197
1452
  }
1198
1453
  }
1199
1454
 
1455
+ const managementMethods = ["shutdown", "addApi", "describe"];
1456
+ for (const method of managementMethods) {
1457
+ const desc = Object.getOwnPropertyDescriptor(source, method);
1458
+ if (desc) {
1459
+ try {
1460
+ Object.defineProperty(target, method, desc);
1461
+ } catch {
1462
+
1463
+ }
1464
+ }
1465
+ }
1466
+
1200
1467
  if (typeof source._impl === "function") {
1201
1468
  target._impl = source._impl;
1202
1469
  }
@@ -1211,3 +1478,5 @@ export default slothlet;
1211
1478
 
1212
1479
 
1213
1480
 
1481
+
1482
+
package/index.cjs CHANGED
@@ -30,9 +30,10 @@
30
30
  * @param {string} [options.mode="singleton"] - Execution mode (singleton, vm, worker, fork)
31
31
  * @param {string} [options.api_mode="auto"] - API structure mode (auto, function, object)
32
32
  * @param {string} [options.runtime] - Runtime type ("async", "asynclocalstorage", "live", "livebindings", "experimental")
33
+ * @param {boolean} [options.allowApiOverwrite=true] - Allow addApi to overwrite existing API endpoints
33
34
  * @param {object} [options.context={}] - Context data for live bindings
34
35
  * @param {object} [options.reference={}] - Reference objects to merge into API root
35
- * @returns {Promise<function|object>} The bound API object with live-binding context
36
+ * @returns {Promise<import("./src/slothlet.mjs").SlothletAPI>} The bound API object with management methods
36
37
  *
37
38
  * @example // CJS usage
38
39
  * const slothlet = require("@cldmv/slothlet");
package/index.mjs CHANGED
@@ -55,9 +55,10 @@ function normalizeRuntimeType(runtime) {
55
55
  * @param {number} [options.apiDepth=Infinity] - Maximum directory depth to scan
56
56
  * @param {boolean} [options.debug=false] - Enable debug logging
57
57
  * @param {string} [options.api_mode="auto"] - API structure mode (auto, function, object)
58
+ * @param {boolean} [options.allowApiOverwrite=true] - Allow addApi to overwrite existing API endpoints
58
59
  * @param {object} [options.context={}] - Context data for live bindings
59
60
  * @param {object} [options.reference={}] - Reference objects to merge into API root
60
- * @returns {Promise<function|object>} The bound API object with live-binding context
61
+ * @returns {Promise<import("./src/slothlet.mjs").SlothletAPI>} The bound API object with management methods
61
62
  *
62
63
  * @example // ESM
63
64
  * import slothlet from "@cldmv/slothlet";
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@cldmv/slothlet",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "moduleVersions": {
5
- "lazy": "1.3.0",
6
- "eager": "1.3.0"
5
+ "lazy": "1.3.1",
6
+ "eager": "1.3.1"
7
7
  },
8
8
  "description": "Slothlet: Modular API Loader for Node.js. Lazy mode dynamically loads API modules and submodules only when accessed, supporting both lazy and eager loading.",
9
9
  "main": "./index.cjs",
@@ -19,5 +19,38 @@
19
19
  * enableAlsForEventEmitters(als);
20
20
  */
21
21
  export function enableAlsForEventEmitters(als?: AsyncLocalStorage<any>): void;
22
+ /**
23
+ * Disable AsyncLocalStorage context propagation for EventEmitter instances.
24
+ *
25
+ * @function disableAlsForEventEmitters
26
+ * @package
27
+ *
28
+ * @description
29
+ * Restores original EventEmitter methods, removing the AsyncLocalStorage
30
+ * context propagation. This should be called during cleanup to prevent
31
+ * hanging AsyncResource instances that can keep the event loop alive.
32
+ *
33
+ * @example
34
+ * // Disable ALS patching during shutdown
35
+ * disableAlsForEventEmitters();
36
+ */
37
+ /**
38
+ * Clean up ALL listeners that went through slothlet's EventEmitter patching.
39
+ *
40
+ * @function cleanupAllSlothletListeners
41
+ * @package
42
+ *
43
+ * @description
44
+ * Removes all event listeners that were registered through slothlet's patched
45
+ * EventEmitter methods. This includes listeners from third-party libraries
46
+ * that got wrapped with AsyncResource instances. This nuclear cleanup option
47
+ * should be called during shutdown to prevent hanging listeners.
48
+ *
49
+ * @example
50
+ * // Clean up all patched listeners during shutdown
51
+ * cleanupAllSlothletListeners();
52
+ */
53
+ export function cleanupAllSlothletListeners(): void;
54
+ export function disableAlsForEventEmitters(): void;
22
55
  import { AsyncLocalStorage } from "node:async_hooks";
23
56
  //# sourceMappingURL=als-eventemitter.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"als-eventemitter.d.mts","sourceRoot":"","sources":["../../../../dist/lib/helpers/als-eventemitter.mjs"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,8EAqJC;kCA9KiC,kBAAkB"}
1
+ {"version":3,"file":"als-eventemitter.d.mts","sourceRoot":"","sources":["../../../../dist/lib/helpers/als-eventemitter.mjs"],"names":[],"mappings":"AAkDA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,8EAiNC;AAED;;;;;;;;;;;;;;GAcG;AACH;;;;;;;;;;;;;;;GAeG;AACH,oDAyBC;AAED,mDAmCC;kCApViC,kBAAkB"}
@@ -55,6 +55,18 @@ export class HookManager {
55
55
  priority?: number;
56
56
  pattern?: string;
57
57
  }): string;
58
+ /**
59
+ * Clean up all hooks and resources
60
+ * @public
61
+ * @description
62
+ * Clears all registered hooks and resets internal state.
63
+ * Should be called during shutdown to prevent memory leaks.
64
+ *
65
+ * @example
66
+ * // Clean up during shutdown
67
+ * manager.cleanup();
68
+ */
69
+ public cleanup(): void;
58
70
  /**
59
71
  * @function off
60
72
  * @public
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.mts","sourceRoot":"","sources":["../../../../dist/lib/helpers/hooks.mjs"],"names":[],"mappings":"AAgCA;;;;;;;;;;;;;GAaG;AACH;IACC;;;;;;OAMG;IACH,sBALW,OAAO,mBACP,MAAM,YAEd;QAA0B,cAAc,GAAhC,OAAO;KACjB,EAQA;IANA,iBAAsB;IACtB,uBAAoC;IACpC,wBAAqD;IACrD,qBAAsB;IACtB,0BAA0B;IAC1B,gCAAmC;IAGpC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,gBAnBW,MAAM,QACN,MAAM,+BAOd;QAAyB,QAAQ,GAAzB,MAAM;QACW,OAAO,GAAxB,MAAM;KACd,GAAU,MAAM,CA0BlB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BAdW,MAAM,GACJ,OAAO,CA6BnB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,oBAdW,MAAM,GACJ,IAAI,CA0BhB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAfW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CA4BzB;IAED;;;;;;;;;;;;OAYG;IACH,wBAVW,MAAM,GACJ,IAAI,CAchB;IAED;;;;;;;;;;;OAWG;IACH,kBATa,IAAI,CAWhB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,2BAkCC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BAwBC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,2BAqBC;IAED;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,0BAyBC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BAkBC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,wBAmBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,sBA4CC;IAED;;;;;;;;;;;;;OAaG;IACH,2BAuBC;IAED;;;;;;;;;;;;;OAaG;IACH,wBAiBC;IAED;;;;;;;;;;;;;;OAcG;IACH,sBAOC;CACD"}
1
+ {"version":3,"file":"hooks.d.mts","sourceRoot":"","sources":["../../../../dist/lib/helpers/hooks.mjs"],"names":[],"mappings":"AAgCA;;;;;;;;;;;;;GAaG;AACH;IACC;;;;;;OAMG;IACH,sBALW,OAAO,mBACP,MAAM,YAEd;QAA0B,cAAc,GAAhC,OAAO;KACjB,EAQA;IANA,iBAAsB;IACtB,uBAAoC;IACpC,wBAAqD;IACrD,qBAAsB;IACtB,0BAA0B;IAC1B,gCAAmC;IAGpC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,gBAnBW,MAAM,QACN,MAAM,+BAOd;QAAyB,QAAQ,GAAzB,MAAM;QACW,OAAO,GAAxB,MAAM;KACd,GAAU,MAAM,CA0BlB;IAED;;;;;;;;;;OAUG;IACH,uBAKC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BAdW,MAAM,GACJ,OAAO,CA6BnB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,oBAdW,MAAM,GACJ,IAAI,CA0BhB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAfW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CA4BzB;IAED;;;;;;;;;;;;OAYG;IACH,wBAVW,MAAM,GACJ,IAAI,CAchB;IAED;;;;;;;;;;;OAWG;IACH,kBATa,IAAI,CAWhB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,2BAkCC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BAwBC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,2BAqBC;IAED;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,0BAyBC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BAkBC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,wBAmBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,sBA4CC;IAED;;;;;;;;;;;;;OAaG;IACH,2BAuBC;IAED;;;;;;;;;;;;;OAaG;IACH,wBAiBC;IAED;;;;;;;;;;;;;;OAcG;IACH,sBAOC;CACD"}
@@ -92,6 +92,13 @@ export type SlothletOptions = {
92
92
  * - `"object"`: Force API to be plain object with method properties
93
93
  */
94
94
  api_mode?: string;
95
+ /**
96
+ * - Controls whether addApi can overwrite existing API endpoints:
97
+ * - `true`: Allow overwrites (default, backwards compatible)
98
+ * - `false`: Prevent overwrites, log warning and skip when attempting to overwrite existing endpoints
99
+ * - Applies to both function and object overwrites at the final key of the API path
100
+ */
101
+ allowApiOverwrite?: boolean;
95
102
  /**
96
103
  * - Context data object injected into live-binding `context` reference.
97
104
  * - Available to all loaded modules via `import { context } from "@cldmv/slothlet/runtime"`. Useful for request data,
@@ -121,14 +128,28 @@ export type SlothletOptions = {
121
128
  };
122
129
  };
123
130
  };
131
+ export type SlothletAPI = {
132
+ /**
133
+ * - Shuts down the API instance and cleans up all resources
134
+ */
135
+ shutdown: () => Promise<void>;
136
+ /**
137
+ * - Dynamically adds API modules from a folder to a specified API path
138
+ */
139
+ addApi: (apiPath: string, folderPath: string) => Promise<void>;
140
+ /**
141
+ * - Returns metadata about the current API instance configuration. In lazy mode with showAll=false, returns an array of property keys. In lazy mode with showAll=true, returns a Promise resolving to an object. In eager mode, returns a plain object.
142
+ */
143
+ describe: (showAll?: boolean) => ((string | symbol)[] | object | Promise<object>);
144
+ };
124
145
  /**
125
146
  * Creates a slothlet API instance with the specified configuration.
126
147
  * This is the main entry point that can be called directly as a function.
127
148
  * @async
128
149
  * @alias module:@cldmv/slothlet
129
150
  * @param {SlothletOptions} [options={}] - Configuration options for creating the API
130
- * @returns {Promise<function|object>} The bound API object or function
151
+ * @returns {Promise<SlothletAPI>} The bound API object or function with management methods
131
152
  * @public
132
153
  */
133
- export function slothlet(options?: SlothletOptions): Promise<Function | object>;
154
+ export function slothlet(options?: SlothletOptions): Promise<SlothletAPI>;
134
155
  //# sourceMappingURL=slothlet.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"slothlet.d.mts","sourceRoot":"","sources":["../../dist/slothlet.mjs"],"names":[],"mappings":"AA+hDA;;;;;;;;;GASG;AACH,kDARW,WAAS,MAAM,UACf,WAAS,MAAM,QAwCzB;AAp5CD;;;;;;;GAOG;AACH,mBAJU,MAAM,CAIO;AAEvB;;;;;GAKG;AACH,sBAJU,MAAM,CAIU;AAE1B;;;;;GAKG;AACH,wBAJU,MAAM,CAIY;;;;;;;;;UAu4Cd,MAAM;;;;;;WAIN,OAAO;;;;;;;;WAGP,MAAM;;;;;;;;aAKN,MAAM;;;;;;;cAKN,MAAM;;;;;;;eAIN,MAAM;;;;;;;;YAIN,OAAO;;;;;;;eAKP,MAAM;;;;;;cAIN,MAAM;;;;;;gBAGN,MAAM;;;;;;eAMjB;QAA8B,UAAU,GAA7B,OAAO;QACY,gBAAgB,GAAnC,OAAO;QACY,gBAAgB,GAAnC,OAAO;QACW,KAAK,GAClC;YAAqC,KAAK,GAA/B,MAAM,EAAE;YACkB,gBAAgB,GAA1C,MAAM,EAAE;YACkB,KAAK,GAA/B,MAAM,EAAE;YACkB,KAAK,GAA/B,MAAM,EAAE;SACrB;KAAA;;AAx7CD;;;;;;;;GAQG;AACH,mCAJW,eAAe,GACb,OAAO,CAAC,WAAS,MAAM,CAAC,CAiCpC"}
1
+ {"version":3,"file":"slothlet.d.mts","sourceRoot":"","sources":["../../dist/slothlet.mjs"],"names":[],"mappings":"AA80DA;;;;;;;;;GASG;AACH,kDARW,WAAS,MAAM,UACf,WAAS,MAAM,QAoDzB;AA9sDD;;;;;;;GAOG;AACH,mBAJU,MAAM,CAIO;AAEvB;;;;;GAKG;AACH,sBAJU,MAAM,CAIU;AAE1B;;;;;GAKG;AACH,wBAJU,MAAM,CAIY;;;;;;;;;UAisDd,MAAM;;;;;;WAIN,OAAO;;;;;;;;WAGP,MAAM;;;;;;;;aAKN,MAAM;;;;;;;cAKN,MAAM;;;;;;;eAIN,MAAM;;;;;;;;YAIN,OAAO;;;;;;;eAKP,MAAM;;;;;;;wBAIN,OAAO;;;;;;cAIP,MAAM;;;;;;gBAGN,MAAM;;;;;;eAMjB;QAA8B,UAAU,GAA7B,OAAO;QACY,gBAAgB,GAAnC,OAAO;QACY,gBAAgB,GAAnC,OAAO;QACW,KAAK,GAClC;YAAqC,KAAK,GAA/B,MAAM,EAAE;YACkB,gBAAgB,GAA1C,MAAM,EAAE;YACkB,KAAK,GAA/B,MAAM,EAAE;YACkB,KAAK,GAA/B,MAAM,EAAE;SACrB;KAAA;;;;;;cAIa,MAAM,OAAO,CAAC,IAAI,CAAC;;;;YACnB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC;;;;cACtD,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC,CAAC,MAAM,GAAC,MAAM,CAAC,EAAE,GAAC,MAAM,GAAC,OAAO,CAAC,MAAM,CAAC,CAAC;;AA5vD/E;;;;;;;;GAQG;AACH,mCAJW,eAAe,GACb,OAAO,CAAC,WAAW,CAAC,CAiChC"}