moost 0.6.35 → 0.6.37

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.cjs CHANGED
@@ -48,7 +48,7 @@ let stamped = false;
48
48
  if (stamped) return;
49
49
  stamped = true;
50
50
  stampModuleIdentity({
51
- version: "0.6.35",
51
+ version: "0.6.37",
52
52
  path: typeof __filename === "string" ? __filename : require("url").pathToFileURL(__filename).href
53
53
  });
54
54
  }
@@ -225,6 +225,18 @@ let loggingOptions = {
225
225
  /** Returns the shared Infact DI container used by Moost for dependency injection. */ function getMoostInfact() {
226
226
  return sharedMoostInfact;
227
227
  }
228
+ /**
229
+ * Every SINGLETON instance currently held by an Infact container's **global**
230
+ * registry (per-event `scopes` registries are excluded — those instances are
231
+ * torn down with their event).
232
+ *
233
+ * Infact keeps the registry as a protected symbol-keyed object; this helper
234
+ * centralizes the one cast needed to read it, so consumers
235
+ * (`Moost.dispose()`, the `@moostjs/vite` dev plugin) do not each re-derive it.
236
+ */ function getInfactSingletonInstances(infact = getMoostInfact()) {
237
+ const { registry } = infact;
238
+ return Object.getOwnPropertySymbols(registry).map((k) => registry[k]).filter(Boolean);
239
+ }
228
240
  const scopeVarsMap = /* @__PURE__ */ new Map();
229
241
  /**
230
242
  * Define global scope name to be used with `@InjectFromScope` and `@InjectScopeVars` decorators
@@ -640,6 +652,126 @@ function nextScopeId() {
640
652
  }
641
653
  }
642
654
 
655
+ //#endregion
656
+ //#region packages/moost/src/dispose.ts
657
+ /**
658
+ * Instances whose dispose hooks already ran. Module-level and weak, so
659
+ * disposal is at-most-once per instance no matter which entry point triggers
660
+ * it — `Moost.dispose()` twice, or a hot-reload eject followed by a shutdown,
661
+ * never re-runs a hook.
662
+ */ const disposedInstances = /* @__PURE__ */ new WeakSet();
663
+ const DISPOSE_BANNER = `moost`;
664
+ /**
665
+ * `Symbol.asyncDispose` / `Symbol.dispose` are only present on newer runtimes —
666
+ * resolve them once through a guarded read so older ones simply have no
667
+ * well-known fallback hook.
668
+ */ const asyncDisposeSymbol = typeof Symbol.asyncDispose === "symbol" ? Symbol.asyncDispose : void 0;
669
+ const disposeSymbol = typeof Symbol.dispose === "symbol" ? Symbol.dispose : void 0;
670
+ /** Class name of an instance, read through the prototype (never touches own props). */ function classNameOf(instance) {
671
+ return Object.getPrototypeOf(instance)?.constructor?.name || "Object";
672
+ }
673
+ /** @internal Message of a thrown value, whatever it is. */ function errorMessage(error) {
674
+ return error instanceof Error ? error.message : String(error);
675
+ }
676
+ /** `Class.method (message)` pairs for every failed hook. */ function formatDisposeErrors(errors) {
677
+ const list = errors.map((e) => `${classNameOf(e.instance)}.${String(e.method)} (${errorMessage(e.error)})`).join(", ");
678
+ return `[moost] ${errors.length} dispose hook(s) failed: ${list}`;
679
+ }
680
+ /**
681
+ * True when `sym` resolves to a data property holding a function anywhere on the
682
+ * instance/prototype chain. Descriptor based — accessors are never invoked.
683
+ */ function hasSymbolMethod(instance, sym) {
684
+ if (!sym) return false;
685
+ let obj = instance;
686
+ while (obj && obj !== Object.prototype) {
687
+ const desc = Object.getOwnPropertyDescriptor(obj, sym);
688
+ if (desc) return typeof desc.value === "function";
689
+ obj = Object.getPrototypeOf(obj);
690
+ }
691
+ return false;
692
+ }
693
+ /**
694
+ * Dispose hooks of one instance: every `@MoostDispose`-decorated method or —
695
+ * when the class decorates none — its `Symbol.asyncDispose` / `Symbol.dispose`
696
+ * method (preferring the async one) as a single priority-0 hook. The method
697
+ * scan goes through `getInstanceOwnMethods`, which classifies by descriptor and
698
+ * never evaluates getters.
699
+ */ function collectHooks(instance) {
700
+ const mate = getMoostMate();
701
+ const hooks = [];
702
+ for (const method of getInstanceOwnMethods(instance)) {
703
+ const priority = mate.read(instance, method)?.moostDispose?.priority;
704
+ if (typeof priority === "number") hooks.push({
705
+ instance,
706
+ method,
707
+ priority
708
+ });
709
+ }
710
+ if (hooks.length > 0) return hooks;
711
+ const sym = hasSymbolMethod(instance, asyncDisposeSymbol) ? asyncDisposeSymbol : hasSymbolMethod(instance, disposeSymbol) ? disposeSymbol : void 0;
712
+ return sym ? [{
713
+ instance,
714
+ method: sym,
715
+ priority: 0
716
+ }] : [];
717
+ }
718
+ async function runHook(hook, errors, opts) {
719
+ const fn = hook.instance[hook.method];
720
+ try {
721
+ await fn.call(hook.instance);
722
+ } catch (error) {
723
+ errors.push({
724
+ instance: hook.instance,
725
+ method: hook.method,
726
+ error
727
+ });
728
+ if (opts?.onError !== "throw") (opts?.logger || getDefaultLogger(DISPOSE_BANNER)).warn(`[moost] dispose hook ${classNameOf(hook.instance)}.${String(hook.method)} failed: ${errorMessage(error)}`);
729
+ }
730
+ }
731
+ /**
732
+ * Runs the dispose hooks of every given instance, once per instance, ever.
733
+ *
734
+ * A hook is either a `@MoostDispose()`-decorated method or, when the class
735
+ * decorates none, the instance's `[Symbol.asyncDispose]()` / `[Symbol.dispose]()`
736
+ * method. Hooks run **sequentially** in ascending `priority` (ties keep input
737
+ * order) and each one is awaited, so teardown that must happen in order (drain
738
+ * consumers, then close the connection they use) can be expressed with
739
+ * priorities.
740
+ *
741
+ * Failures never stop the run: with `onError: 'warn'` (default) each one is
742
+ * logged and collected; with `onError: 'throw'` every hook still runs and one
743
+ * `AggregateError` is thrown at the end.
744
+ *
745
+ * Instances already disposed (by an earlier call, or by `Moost.dispose()`) are
746
+ * skipped, so passing overlapping sets is safe.
747
+ */ async function disposeInstances(instances, opts) {
748
+ const hooks = [];
749
+ const disposed = [];
750
+ for (const instance of instances) {
751
+ if (!instance || disposedInstances.has(instance)) continue;
752
+ disposedInstances.add(instance);
753
+ const own = collectHooks(instance);
754
+ if (own.length > 0) {
755
+ disposed.push(instance);
756
+ hooks.push(...own);
757
+ }
758
+ }
759
+ const errors = [];
760
+ for (const hook of hooks.toSorted((a, b) => a.priority - b.priority)) await runHook(hook, errors, opts);
761
+ if (opts?.onError === "throw" && errors.length > 0) throw new AggregateError(errors.map((e) => e.error), formatDisposeErrors(errors));
762
+ return {
763
+ hooks: hooks.length,
764
+ errors,
765
+ disposed
766
+ };
767
+ }
768
+ /**
769
+ * @internal Renders the aggregate message used when disposal failures are
770
+ * rethrown (`Moost.dispose()` and `disposeInstances(..., { onError: 'throw' })`).
771
+ */ function describeDisposeErrors(errors) {
772
+ return formatDisposeErrors(errors);
773
+ }
774
+
643
775
  //#endregion
644
776
  //#region packages/moost/src/decorators/circular.decorator.ts
645
777
  /**
@@ -767,6 +899,56 @@ function ImportController(prefix, controller, provide) {
767
899
  }, true);
768
900
  }
769
901
 
902
+ //#endregion
903
+ //#region packages/moost/src/decorators/dispose.decorator.ts
904
+ /**
905
+ * ## MoostDispose
906
+ * ### @Decorator
907
+ * Marks a method to run **once**, when its instance is **disposed** — either by
908
+ * `Moost.dispose()` (graceful shutdown) or by the `@moostjs/vite` dev server
909
+ * when the instance is ejected on hot reload. It is the teardown counterpart of
910
+ * {@link MoostInit}: release what the singleton owns (a queue consumer, a cache
911
+ * client, a database handle, timers, watchers) so a replaced instance does not
912
+ * leak it.
913
+ *
914
+ * Works on SINGLETON controllers **and** on any `@Injectable()` singleton — the
915
+ * hooks are discovered from live instances at dispose time, so a provider that
916
+ * owns a connection is covered without being a controller.
917
+ *
918
+ * The method takes **no arguments** (dispose tears down an existing instance —
919
+ * inject whatever it needs in the constructor). Async methods are awaited.
920
+ * Errors are collected, not fatal: every other hook still runs, `Moost.dispose()`
921
+ * then rejects with an `AggregateError`, and a hot reload only warns.
922
+ *
923
+ * An instance is disposed at most once (tracked per instance), so a second
924
+ * `dispose()` — or an eject followed by a shutdown — never re-runs a hook.
925
+ *
926
+ * When a class defines no `@MoostDispose` method but exposes
927
+ * `[Symbol.asyncDispose]()` / `[Symbol.dispose]()`, that is used instead.
928
+ *
929
+ * Applying `@MoostDispose` to a `FOR_EVENT` **controller** is a configuration
930
+ * error and throws at bind time (per-event instances are torn down with their
931
+ * event — put the hook on the singleton that owns the resource). On a
932
+ * `FOR_EVENT` **injectable** the hook is simply never invoked.
933
+ *
934
+ * @param opts.priority lower runs first across all `@MoostDispose` methods (default 0)
935
+ *
936
+ * @example
937
+ * ```ts
938
+ * │ @Injectable()
939
+ * │ export class CacheClient {
940
+ * │ private readonly client = createCacheClient()
941
+ * │
942
+ * │ @MoostDispose()
943
+ * │ async close() {
944
+ * │ await this.client.quit()
945
+ * │ }
946
+ * │ }
947
+ * ```
948
+ */ function MoostDispose(opts) {
949
+ return getMoostMate().decorate("moostDispose", { priority: opts?.priority ?? 0 }, false);
950
+ }
951
+
770
952
  //#endregion
771
953
  //#region packages/moost/src/decorators/resolve.decorator.ts
772
954
  /**
@@ -1612,6 +1794,7 @@ async function bindControllerMethods(options) {
1612
1794
  className: classConstructor.name,
1613
1795
  methodName: method
1614
1796
  }));
1797
+ if (methodMeta.moostDispose && meta.injectable === "FOR_EVENT") throw new Error(`@MoostDispose is not allowed on a FOR_EVENT controller (${classConstructor.name}.${String(method)}). Dispose hooks run on the SINGLETON instance; FOR_EVENT instances are torn down with their event — put the hook on the singleton that owns the resource.`);
1615
1798
  if (methodMeta.moostInit) {
1616
1799
  if (meta.injectable === "FOR_EVENT") throw new Error(`@MoostInit is not allowed on a FOR_EVENT controller (${classConstructor.name}.${String(method)}). Init hooks run once at boot on the SINGLETON instance; FOR_EVENT controllers have no init-time instance.`);
1617
1800
  const initArgsPipes = (methodMeta.params || []).map((p) => ({
@@ -1769,6 +1952,67 @@ function lostCtorParamsMessage(input, ancestor, count) {
1769
1952
  return [auditRouteDrop(input), auditLostCtorParams(input)].filter((f) => f !== void 0);
1770
1953
  }
1771
1954
 
1955
+ //#endregion
1956
+ //#region packages/moost/src/dispose-signals.ts
1957
+ /**
1958
+ * Global key for the record above. Global (rather than module-scoped) for the
1959
+ * same reason the module-identity stamp is: two copies of moost, or the entry
1960
+ * re-executing under the `@moostjs/vite` dev server, must share one set of
1961
+ * process listeners instead of stacking one per app.
1962
+ */ const DISPOSE_SIGNALS_KEY = Symbol.for("moost:dispose-on-signals");
1963
+ /**
1964
+ * Removes every registered listener and forgets the record. A no-op for a record
1965
+ * that is no longer the live one (a stale unregister fn, or a signal that already
1966
+ * dropped it).
1967
+ */ function dropSignalsRecord(record) {
1968
+ const holder = globalThis;
1969
+ if (holder[DISPOSE_SIGNALS_KEY] !== record) return;
1970
+ for (const [signal, listener] of record.listeners) process.off(signal, listener);
1971
+ record.listeners.clear();
1972
+ delete holder[DISPOSE_SIGNALS_KEY];
1973
+ }
1974
+ /**
1975
+ * The one listener body per signal (NestJS `enableShutdownHooks` semantics):
1976
+ * unhook every listener first, dispose the newest app, then re-raise `signal`
1977
+ * so the process exits with Node's default status for it. Because the listeners
1978
+ * are already gone, a second signal while disposal runs gets Node's default
1979
+ * handling (exit `128 + n`) — an impatient Ctrl-C is never stuck.
1980
+ */ async function onSignal(signal) {
1981
+ const record = globalThis[DISPOSE_SIGNALS_KEY];
1982
+ if (!record) return;
1983
+ const { app } = record;
1984
+ dropSignalsRecord(record);
1985
+ try {
1986
+ await app.dispose();
1987
+ } catch (error) {
1988
+ app.getLogger().warn(`[moost] dispose on ${signal} failed: ${errorMessage(error)}`);
1989
+ }
1990
+ process.kill(process.pid, signal);
1991
+ }
1992
+ /**
1993
+ * @internal Backs `Moost.disposeOnSignals()` — see that method's JSDoc for the
1994
+ * full contract. Registers at most one process listener per signal, ever, and
1995
+ * re-targets the existing ones at `app`.
1996
+ */ function registerDisposeOnSignals(app, signals) {
1997
+ var _holder, _DISPOSE_SIGNALS_KEY;
1998
+ const record = (_holder = globalThis)[_DISPOSE_SIGNALS_KEY = DISPOSE_SIGNALS_KEY] ?? (_holder[_DISPOSE_SIGNALS_KEY] = {
1999
+ app,
2000
+ listeners: /* @__PURE__ */ new Map()
2001
+ });
2002
+ record.app = app;
2003
+ for (const signal of signals) {
2004
+ if (record.listeners.has(signal)) continue;
2005
+ const listener = () => {
2006
+ onSignal(signal);
2007
+ };
2008
+ record.listeners.set(signal, listener);
2009
+ process.on(signal, listener);
2010
+ }
2011
+ return () => {
2012
+ dropSignalsRecord(record);
2013
+ };
2014
+ }
2015
+
1772
2016
  //#endregion
1773
2017
  //#region packages/moost/src/moost.ts
1774
2018
  function _define_property(obj, key, value) {
@@ -1932,6 +2176,84 @@ function _define_property(obj, key, value) {
1932
2176
  });
1933
2177
  }
1934
2178
  /**
2179
+ * ### dispose
2180
+ * Graceful shutdown: stops the adapters, then runs every `@MoostDispose`
2181
+ * hook of every singleton this app can reach — DI registry singletons
2182
+ * (`@Injectable()` providers included) plus the controller instances bound at
2183
+ * boot. A class with no decorated hook but an `[Symbol.asyncDispose]()` /
2184
+ * `[Symbol.dispose]()` method is disposed through that.
2185
+ *
2186
+ * Order: every adapter's `onDispose` in registration order (stop taking new
2187
+ * work first), then instance hooks by ascending `priority`. Everything is
2188
+ * awaited, failures never stop the run — once all hooks have run, a failed
2189
+ * disposal rejects the returned promise with an `AggregateError` naming each
2190
+ * failing `Class.method`.
2191
+ *
2192
+ * Idempotent: the promise of the first call is returned for every later one,
2193
+ * and an instance already disposed (e.g. ejected by the `@moostjs/vite` dev
2194
+ * server) is skipped.
2195
+ *
2196
+ * DI registries and metadata caches are left intact — use the existing
2197
+ * cleanup helpers when a clean container is what you need.
2198
+ *
2199
+ * ```ts
2200
+ * app.disposeOnSignals() // SIGTERM + SIGINT → dispose() → re-raise
2201
+ * ```
2202
+ */ dispose() {
2203
+ if (!this.disposePromise) this.disposePromise = this.runDispose();
2204
+ return this.disposePromise;
2205
+ }
2206
+ /**
2207
+ * ### disposeOnSignals
2208
+ * Disposes this app when the process receives one of `signals` (default
2209
+ * `['SIGTERM', 'SIGINT']`), then re-raises the signal with no listener left,
2210
+ * so the process exits with Node's default status for it.
2211
+ *
2212
+ * Registers each process listener **once per process**: calling it again —
2213
+ * e.g. the entry re-executing under the `@moostjs/vite` dev server — re-targets
2214
+ * the existing listeners at the newest app instead of stacking one handler per
2215
+ * old app (which is what a plain `process.once(...)` in the entry does, until
2216
+ * Node warns about a listener leak). Signals are unioned across calls; an
2217
+ * already-registered one is never replaced or removed.
2218
+ *
2219
+ * A second signal while disposal runs gets Node's default handling, since the
2220
+ * listeners are already removed. A failing `dispose()` is logged as a warning
2221
+ * and the signal is re-raised all the same.
2222
+ *
2223
+ * Returns a function that unregisters the listeners (tests, embedded runners).
2224
+ *
2225
+ * ```ts
2226
+ * await app.init()
2227
+ * app.disposeOnSignals()
2228
+ * ```
2229
+ */ disposeOnSignals(signals = ["SIGTERM", "SIGINT"]) {
2230
+ return registerDisposeOnSignals(this, signals);
2231
+ }
2232
+ /** Adapters' `onDispose` in registration order; errors are collected, not fatal. */ async disposeAdapters() {
2233
+ const errors = [];
2234
+ for (const a of this.adapters) try {
2235
+ await a.onDispose?.(this);
2236
+ } catch (error) {
2237
+ errors.push({
2238
+ instance: a,
2239
+ method: "onDispose",
2240
+ error
2241
+ });
2242
+ this.logger.warn(`[moost] adapter "${a.name}" onDispose failed: ${errorMessage(error)}`);
2243
+ }
2244
+ return errors;
2245
+ }
2246
+ /** The one-shot body behind {@link Moost.dispose} (see its docs for the contract). */ async runDispose() {
2247
+ const errors = await this.disposeAdapters();
2248
+ const result = await disposeInstances([...getInfactSingletonInstances(), ...this.singletonInstances], {
2249
+ logger: this.logger,
2250
+ onError: "warn"
2251
+ });
2252
+ errors.push(...result.errors);
2253
+ this.logger.debug(`[moost] disposed: ${this.adapters.length} adapter(s), ${result.hooks} hook(s), ${errors.length} error(s)`);
2254
+ if (errors.length > 0) throw new AggregateError(errors.map((e) => e.error), describeDisposeErrors(errors));
2255
+ }
2256
+ /**
1935
2257
  * D1 audit of a DI-instantiated controller class' constructor params.
1936
2258
  * Findings are collected for `init()` to flush. Returns `true` when the
1937
2259
  * bind-time SINGLETON instantiation must be skipped: in `'error'` mode a
@@ -1990,6 +2312,7 @@ function _define_property(obj, key, value) {
1990
2312
  instance = controller;
1991
2313
  infact.setInstanceRegistries(instance, provide, replace, { pipes });
1992
2314
  }
2315
+ if (instance) this.singletonInstances.add(instance);
1993
2316
  const getInstance = instance ? () => instance : async () => await infact.get(controller, { ...infactOpts });
1994
2317
  const classConstructor = (0, _prostojs_mate.isConstructor)(controller) ? controller : (0, _prostojs_mate.getConstructor)(controller);
1995
2318
  const controllerOverview = await bindControllerMethods({
@@ -2165,7 +2488,7 @@ function _define_property(obj, key, value) {
2165
2488
  this.logger.info(`${prefix || ""}${c}${eventName} ${"\x1B[0m\x1B[2m\x1B[32m" + c}→ ${classConstructor.name}.${"\x1B[36m" + c}${method}()${coff}`);
2166
2489
  }
2167
2490
  constructor(options) {
2168
- super(), _define_property(this, "options", void 0), _define_property(this, "logger", void 0), _define_property(this, "pipes", void 0), _define_property(this, "interceptors", void 0), _define_property(this, "adapters", void 0), _define_property(this, "controllersOverview", void 0), _define_property(this, "handlerOverviewIndex", void 0), _define_property(this, "initHooks", void 0), _define_property(this, "provide", void 0), _define_property(this, "replace", void 0), _define_property(this, "unregisteredControllers", void 0), _define_property(this, "paramAuditFindings", void 0), _define_property(this, "paramAuditMode", void 0), _define_property(this, "inheritanceAuditMode", void 0), _define_property(this, "initialized", void 0), _define_property(this, "globalInterceptorHandler", void 0), this.options = options, this.pipes = Array.from(sharedPipes), this.interceptors = [], this.adapters = [], this.controllersOverview = [], this.initHooks = [], this.provide = (0, _prostojs_infact.createProvideRegistry)([_prostojs_infact.Infact, getMoostInfact], [_prostojs_mate.Mate, getMoostMate]), this.replace = {}, this.unregisteredControllers = [], this.paramAuditFindings = [], this.initialized = false;
2491
+ super(), _define_property(this, "options", void 0), _define_property(this, "logger", void 0), _define_property(this, "pipes", void 0), _define_property(this, "interceptors", void 0), _define_property(this, "adapters", void 0), _define_property(this, "controllersOverview", void 0), _define_property(this, "handlerOverviewIndex", void 0), _define_property(this, "initHooks", void 0), _define_property(this, "provide", void 0), _define_property(this, "replace", void 0), _define_property(this, "unregisteredControllers", void 0), _define_property(this, "paramAuditFindings", void 0), _define_property(this, "paramAuditMode", void 0), _define_property(this, "inheritanceAuditMode", void 0), _define_property(this, "initialized", void 0), _define_property(this, "singletonInstances", void 0), _define_property(this, "disposePromise", void 0), _define_property(this, "globalInterceptorHandler", void 0), this.options = options, this.pipes = Array.from(sharedPipes), this.interceptors = [], this.adapters = [], this.controllersOverview = [], this.initHooks = [], this.provide = (0, _prostojs_infact.createProvideRegistry)([_prostojs_infact.Infact, getMoostInfact], [_prostojs_mate.Mate, getMoostMate]), this.replace = {}, this.unregisteredControllers = [], this.paramAuditFindings = [], this.initialized = false, this.singletonInstances = /* @__PURE__ */ new Set();
2169
2492
  this.paramAuditMode = resolveParamAuditMode(options?.diagnostics?.paramTypes);
2170
2493
  this.inheritanceAuditMode = options?.diagnostics?.inheritance || "warn";
2171
2494
  this.logger = options?.logger || getDefaultLogger(`moost`);
@@ -2275,6 +2598,7 @@ exports.Label = Label;
2275
2598
  exports.LoggerTopic = LoggerTopic;
2276
2599
  exports.MOOST_MODULE_IDENTITY_KEY = MOOST_MODULE_IDENTITY_KEY;
2277
2600
  exports.Moost = Moost;
2601
+ exports.MoostDispose = MoostDispose;
2278
2602
  exports.MoostInit = MoostInit;
2279
2603
  exports.OnError = OnError;
2280
2604
  exports.Optional = Optional;
@@ -2340,6 +2664,7 @@ exports.defineInfactScope = defineInfactScope;
2340
2664
  exports.defineInterceptor = defineInterceptor;
2341
2665
  exports.defineMoostEventHandler = defineMoostEventHandler;
2342
2666
  exports.definePipeFn = definePipeFn;
2667
+ exports.disposeInstances = disposeInstances;
2343
2668
  Object.defineProperty(exports, 'eventTypeKey', {
2344
2669
  enumerable: true,
2345
2670
  get: function () {
@@ -2366,6 +2691,7 @@ Object.defineProperty(exports, 'getGlobalWooks', {
2366
2691
  });
2367
2692
  exports.getHandlerPaths = getHandlerPaths;
2368
2693
  exports.getInfactScopeVars = getInfactScopeVars;
2694
+ exports.getInfactSingletonInstances = getInfactSingletonInstances;
2369
2695
  exports.getInstanceOwnMethods = getInstanceOwnMethods;
2370
2696
  exports.getInstanceOwnProps = getInstanceOwnProps;
2371
2697
  exports.getMoostInfact = getMoostInfact;
package/dist/index.d.ts CHANGED
@@ -168,6 +168,55 @@ declare function ImportController(controller: TFunction | TObject, provide?: TPr
168
168
  */
169
169
  declare function ImportController(prefix: string, controller: TFunction | TObject, provide?: TProvideRegistry): ClassDecorator;
170
170
 
171
+ /**
172
+ * ## MoostDispose
173
+ * ### @Decorator
174
+ * Marks a method to run **once**, when its instance is **disposed** — either by
175
+ * `Moost.dispose()` (graceful shutdown) or by the `@moostjs/vite` dev server
176
+ * when the instance is ejected on hot reload. It is the teardown counterpart of
177
+ * {@link MoostInit}: release what the singleton owns (a queue consumer, a cache
178
+ * client, a database handle, timers, watchers) so a replaced instance does not
179
+ * leak it.
180
+ *
181
+ * Works on SINGLETON controllers **and** on any `@Injectable()` singleton — the
182
+ * hooks are discovered from live instances at dispose time, so a provider that
183
+ * owns a connection is covered without being a controller.
184
+ *
185
+ * The method takes **no arguments** (dispose tears down an existing instance —
186
+ * inject whatever it needs in the constructor). Async methods are awaited.
187
+ * Errors are collected, not fatal: every other hook still runs, `Moost.dispose()`
188
+ * then rejects with an `AggregateError`, and a hot reload only warns.
189
+ *
190
+ * An instance is disposed at most once (tracked per instance), so a second
191
+ * `dispose()` — or an eject followed by a shutdown — never re-runs a hook.
192
+ *
193
+ * When a class defines no `@MoostDispose` method but exposes
194
+ * `[Symbol.asyncDispose]()` / `[Symbol.dispose]()`, that is used instead.
195
+ *
196
+ * Applying `@MoostDispose` to a `FOR_EVENT` **controller** is a configuration
197
+ * error and throws at bind time (per-event instances are torn down with their
198
+ * event — put the hook on the singleton that owns the resource). On a
199
+ * `FOR_EVENT` **injectable** the hook is simply never invoked.
200
+ *
201
+ * @param opts.priority lower runs first across all `@MoostDispose` methods (default 0)
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * │ @Injectable()
206
+ * │ export class CacheClient {
207
+ * │ private readonly client = createCacheClient()
208
+ * │
209
+ * │ @MoostDispose()
210
+ * │ async close() {
211
+ * │ await this.client.quit()
212
+ * │ }
213
+ * │ }
214
+ * ```
215
+ */
216
+ declare function MoostDispose(opts?: {
217
+ priority?: number;
218
+ }): MethodDecorator;
219
+
171
220
  type TDecoratorLevel = 'CLASS' | 'METHOD' | 'PROP' | 'PARAM';
172
221
 
173
222
  /** Metadata context passed to pipe functions during argument/property resolution. */
@@ -311,6 +360,14 @@ interface TMoostMetadata<H extends TObject = TEmpty> extends TCommonMetaFields,
311
360
  moostInit?: {
312
361
  priority: number;
313
362
  };
363
+ /**
364
+ * Set by `@MoostDispose()` — marks a method to run when the instance is
365
+ * disposed (`Moost.dispose()`, or a hot-reload eject in `@moostjs/vite`).
366
+ * `priority` orders hooks (ascending) across all instances.
367
+ */
368
+ moostDispose?: {
369
+ priority: number;
370
+ };
314
371
  params: (TMateParamMeta & TMoostParamsMetadata)[];
315
372
  }
316
373
  /** Metadata attached to constructor/method parameters by Moost decorators. */
@@ -365,6 +422,16 @@ interface TInfactLoggingOptions {
365
422
  declare function setInfactLoggingOptions(options: TInfactLoggingOptions): void;
366
423
  /** Returns the shared Infact DI container used by Moost for dependency injection. */
367
424
  declare function getMoostInfact(): Infact<TMoostMetadata<TEmpty>, TMoostMetadata<TEmpty>, TMoostParamsMetadata, TCustom>;
425
+ /**
426
+ * Every SINGLETON instance currently held by an Infact container's **global**
427
+ * registry (per-event `scopes` registries are excluded — those instances are
428
+ * torn down with their event).
429
+ *
430
+ * Infact keeps the registry as a protected symbol-keyed object; this helper
431
+ * centralizes the one cast needed to read it, so consumers
432
+ * (`Moost.dispose()`, the `@moostjs/vite` dev plugin) do not each re-derive it.
433
+ */
434
+ declare function getInfactSingletonInstances(infact?: ReturnType<typeof getMoostInfact>): object[];
368
435
  interface TCustom {
369
436
  pipes?: TPipeData[];
370
437
  }
@@ -417,6 +484,53 @@ interface TInitHook {
417
484
  /** Mode for the bind-time inheritance audit (see `TMoostOptions['diagnostics']`). */
418
485
  type TInheritanceAuditMode = 'warn' | 'off';
419
486
 
487
+ /** One failed dispose hook: the instance, the method that threw, and the thrown value. */
488
+ interface TDisposeError {
489
+ instance: object;
490
+ method: string | symbol;
491
+ error: unknown;
492
+ }
493
+ /** Outcome of {@link disposeInstances}: how many hooks ran, and which of them failed. */
494
+ interface TDisposeResult {
495
+ /** Number of hooks invoked (a class may declare several). */
496
+ hooks: number;
497
+ /** Failed hooks, in run order. */
498
+ errors: TDisposeError[];
499
+ /**
500
+ * Instances that had at least one hook invoked — i.e. the ones actually torn
501
+ * down, as opposed to the ones passed in that declare no hook at all.
502
+ */
503
+ disposed: object[];
504
+ }
505
+ /** Options for {@link disposeInstances}. */
506
+ interface TDisposeOptions {
507
+ /** Logger for `onError: 'warn'` diagnostics; defaults to the Moost default logger. */
508
+ logger?: TConsoleBase;
509
+ /**
510
+ * `'warn'` (default) — log every failure and keep going;
511
+ * `'throw'` — still run every hook, then throw one `AggregateError`.
512
+ */
513
+ onError?: 'warn' | 'throw';
514
+ }
515
+ /**
516
+ * Runs the dispose hooks of every given instance, once per instance, ever.
517
+ *
518
+ * A hook is either a `@MoostDispose()`-decorated method or, when the class
519
+ * decorates none, the instance's `[Symbol.asyncDispose]()` / `[Symbol.dispose]()`
520
+ * method. Hooks run **sequentially** in ascending `priority` (ties keep input
521
+ * order) and each one is awaited, so teardown that must happen in order (drain
522
+ * consumers, then close the connection they use) can be expressed with
523
+ * priorities.
524
+ *
525
+ * Failures never stop the run: with `onError: 'warn'` (default) each one is
526
+ * logged and collected; with `onError: 'throw'` every hook still runs and one
527
+ * `AggregateError` is thrown at the end.
528
+ *
529
+ * Instances already disposed (by an earlier call, or by `Moost.dispose()`) are
530
+ * skipped, so passing overlapping sets is safe.
531
+ */
532
+ declare function disposeInstances(instances: Iterable<object>, opts?: TDisposeOptions): Promise<TDisposeResult>;
533
+
420
534
  interface TMoostOptions {
421
535
  /**
422
536
  * Global path prefix — mounts the whole app under one path segment.
@@ -580,6 +694,15 @@ declare class Moost extends Hookable {
580
694
  protected readonly inheritanceAuditMode: TInheritanceAuditMode;
581
695
  /** D5: set once `init()` completes — late provide/replace registrations then warn. */
582
696
  protected initialized: boolean;
697
+ /**
698
+ * SINGLETON controller instances this app obtained at bind time (DI-resolved
699
+ * or registered as objects). They are disposed together with the DI registry
700
+ * singletons — an object-registered controller never enters the Infact
701
+ * registry, so it would otherwise be missed.
702
+ */
703
+ protected singletonInstances: Set<object>;
704
+ /** In-flight/settled `dispose()` run — makes disposal idempotent. */
705
+ protected disposePromise?: Promise<void>;
583
706
  constructor(options?: TMoostOptions | undefined);
584
707
  _fireEventStart(source: TMoostAdapter<unknown>): void;
585
708
  _fireEventEnd(source: TMoostAdapter<unknown>): void;
@@ -617,6 +740,61 @@ declare class Moost extends Hookable {
617
740
  * A throwing hook rejects `init()` (fail-fast).
618
741
  */
619
742
  protected runInitHooks(): Promise<void>;
743
+ /**
744
+ * ### dispose
745
+ * Graceful shutdown: stops the adapters, then runs every `@MoostDispose`
746
+ * hook of every singleton this app can reach — DI registry singletons
747
+ * (`@Injectable()` providers included) plus the controller instances bound at
748
+ * boot. A class with no decorated hook but an `[Symbol.asyncDispose]()` /
749
+ * `[Symbol.dispose]()` method is disposed through that.
750
+ *
751
+ * Order: every adapter's `onDispose` in registration order (stop taking new
752
+ * work first), then instance hooks by ascending `priority`. Everything is
753
+ * awaited, failures never stop the run — once all hooks have run, a failed
754
+ * disposal rejects the returned promise with an `AggregateError` naming each
755
+ * failing `Class.method`.
756
+ *
757
+ * Idempotent: the promise of the first call is returned for every later one,
758
+ * and an instance already disposed (e.g. ejected by the `@moostjs/vite` dev
759
+ * server) is skipped.
760
+ *
761
+ * DI registries and metadata caches are left intact — use the existing
762
+ * cleanup helpers when a clean container is what you need.
763
+ *
764
+ * ```ts
765
+ * app.disposeOnSignals() // SIGTERM + SIGINT → dispose() → re-raise
766
+ * ```
767
+ */
768
+ dispose(): Promise<void>;
769
+ /**
770
+ * ### disposeOnSignals
771
+ * Disposes this app when the process receives one of `signals` (default
772
+ * `['SIGTERM', 'SIGINT']`), then re-raises the signal with no listener left,
773
+ * so the process exits with Node's default status for it.
774
+ *
775
+ * Registers each process listener **once per process**: calling it again —
776
+ * e.g. the entry re-executing under the `@moostjs/vite` dev server — re-targets
777
+ * the existing listeners at the newest app instead of stacking one handler per
778
+ * old app (which is what a plain `process.once(...)` in the entry does, until
779
+ * Node warns about a listener leak). Signals are unioned across calls; an
780
+ * already-registered one is never replaced or removed.
781
+ *
782
+ * A second signal while disposal runs gets Node's default handling, since the
783
+ * listeners are already removed. A failing `dispose()` is logged as a warning
784
+ * and the signal is re-raised all the same.
785
+ *
786
+ * Returns a function that unregisters the listeners (tests, embedded runners).
787
+ *
788
+ * ```ts
789
+ * await app.init()
790
+ * app.disposeOnSignals()
791
+ * ```
792
+ */
793
+ disposeOnSignals(signals?: NodeJS.Signals[]): () => void;
794
+ /** Adapters' `onDispose` in registration order; errors are collected, not fatal. */
795
+ protected disposeAdapters(): Promise<TDisposeError[]>;
796
+ /** The one-shot body behind {@link Moost.dispose} (see its docs for the contract). */
797
+ protected runDispose(): Promise<void>;
620
798
  /**
621
799
  * D1 audit of a DI-instantiated controller class' constructor params.
622
800
  * Findings are collected for `init()` to flush. Returns `true` when the
@@ -719,6 +897,13 @@ interface TMoostAdapter<H> {
719
897
  name: string;
720
898
  bindHandler: <T extends TObject = TObject>(options: TMoostAdapterOptions<H, T>) => void | Promise<void>;
721
899
  onInit?: (moost: Moost) => void | Promise<void>;
900
+ /**
901
+ * Called by {@link Moost.dispose} **before** any `@MoostDispose` instance
902
+ * hook, in adapter registration order: stop taking new work here (close the
903
+ * server, stop the consumer/engine). Awaited; a throwing `onDispose` is
904
+ * logged and collected, never aborts the rest of the shutdown.
905
+ */
906
+ onDispose?: (moost: Moost) => void | Promise<void>;
722
907
  getProvideRegistry?: () => TProvideRegistry;
723
908
  }
724
909
 
@@ -1270,5 +1455,5 @@ declare function createLogger(opts?: Partial<TProstoLoggerOptions>): ProstoLogge
1270
1455
  /** Default colored console transport used by Moost loggers. */
1271
1456
  declare const loggerConsoleTransport: _prostojs_logger.TProstoLoggerTransportFn<any>;
1272
1457
 
1273
- export { After, ApplyDecorators, Before, Circular, Const, ConstFactory, Controller, Description, HandlerPaths, Id, ImportController, Inherit, Inject, InjectEventLogger, InjectFromScope, InjectMoost, InjectMoostLogger, InjectScopeVars, Injectable, Intercept, Interceptor, InterceptorHandler, Label, LoggerTopic, MOOST_MODULE_IDENTITY_KEY, Moost, MoostInit, OnError, Optional, Overtake, Param, Params, Pipe, Provide, Replace, Required, Resolve, Response, TInterceptorPriority, TPipePriority, Value, createLogger, defineAfterInterceptor, defineBeforeInterceptor, defineErrorInterceptor, defineInfactScope, defineInterceptor, defineMoostEventHandler, definePipeFn, getHandlerPaths, getInfactScopeVars, getInstanceOwnMethods, getInstanceOwnProps, getMoostInfact, getMoostMate, getNewMoostInfact, globalKey, isThenable, loggerConsoleTransport, mergeSorted, registerEventScope, resolvePipe, setControllerContext, setInfactLoggingOptions, setInterceptResult, setOvertake, stampModuleIdentity, stampOnce, useControllerContext, useHandlerPaths, useInterceptResult, useOvertake, useScopeId };
1274
- export type { TAny, TAnyFn, TClassConstructor, TContextInjectorHook, TControllerOverview, TControllerRegistration, TControllersGroup, TEmpty, TFunction, TGetHandlerPathsOptions, TInjectableScope, TInterceptorAfterFn, TInterceptorBeforeFn, TInterceptorData, TInterceptorDef, TInterceptorDefFactory, TInterceptorEntry, TInterceptorErrorFn, TLogger, TMoostAdapter, TMoostAdapterOptions, TMoostEventHandlerHookOptions, TMoostEventHandlerOptions, TMoostHandler, TMoostMetadata, TMoostModuleIdentity, TMoostOptions, TMoostParamsMetadata, TObject, TOvertakeFn, TPipeData, TPipeFn, TPipeMetas, TPrimitives };
1458
+ export { After, ApplyDecorators, Before, Circular, Const, ConstFactory, Controller, Description, HandlerPaths, Id, ImportController, Inherit, Inject, InjectEventLogger, InjectFromScope, InjectMoost, InjectMoostLogger, InjectScopeVars, Injectable, Intercept, Interceptor, InterceptorHandler, Label, LoggerTopic, MOOST_MODULE_IDENTITY_KEY, Moost, MoostDispose, MoostInit, OnError, Optional, Overtake, Param, Params, Pipe, Provide, Replace, Required, Resolve, Response, TInterceptorPriority, TPipePriority, Value, createLogger, defineAfterInterceptor, defineBeforeInterceptor, defineErrorInterceptor, defineInfactScope, defineInterceptor, defineMoostEventHandler, definePipeFn, disposeInstances, getHandlerPaths, getInfactScopeVars, getInfactSingletonInstances, getInstanceOwnMethods, getInstanceOwnProps, getMoostInfact, getMoostMate, getNewMoostInfact, globalKey, isThenable, loggerConsoleTransport, mergeSorted, registerEventScope, resolvePipe, setControllerContext, setInfactLoggingOptions, setInterceptResult, setOvertake, stampModuleIdentity, stampOnce, useControllerContext, useHandlerPaths, useInterceptResult, useOvertake, useScopeId };
1459
+ export type { TAny, TAnyFn, TClassConstructor, TContextInjectorHook, TControllerOverview, TControllerRegistration, TControllersGroup, TDisposeError, TDisposeOptions, TDisposeResult, TEmpty, TFunction, TGetHandlerPathsOptions, TInjectableScope, TInterceptorAfterFn, TInterceptorBeforeFn, TInterceptorData, TInterceptorDef, TInterceptorDefFactory, TInterceptorEntry, TInterceptorErrorFn, TLogger, TMoostAdapter, TMoostAdapterOptions, TMoostEventHandlerHookOptions, TMoostEventHandlerOptions, TMoostHandler, TMoostMetadata, TMoostModuleIdentity, TMoostOptions, TMoostParamsMetadata, TObject, TOvertakeFn, TPipeData, TPipeFn, TPipeMetas, TPrimitives };
package/dist/index.mjs CHANGED
@@ -47,7 +47,7 @@ let stamped = false;
47
47
  if (stamped) return;
48
48
  stamped = true;
49
49
  stampModuleIdentity({
50
- version: "0.6.35",
50
+ version: "0.6.37",
51
51
  path: typeof __filename === "string" ? __filename : import.meta.url
52
52
  });
53
53
  }
@@ -224,6 +224,18 @@ let loggingOptions = {
224
224
  /** Returns the shared Infact DI container used by Moost for dependency injection. */ function getMoostInfact() {
225
225
  return sharedMoostInfact;
226
226
  }
227
+ /**
228
+ * Every SINGLETON instance currently held by an Infact container's **global**
229
+ * registry (per-event `scopes` registries are excluded — those instances are
230
+ * torn down with their event).
231
+ *
232
+ * Infact keeps the registry as a protected symbol-keyed object; this helper
233
+ * centralizes the one cast needed to read it, so consumers
234
+ * (`Moost.dispose()`, the `@moostjs/vite` dev plugin) do not each re-derive it.
235
+ */ function getInfactSingletonInstances(infact = getMoostInfact()) {
236
+ const { registry } = infact;
237
+ return Object.getOwnPropertySymbols(registry).map((k) => registry[k]).filter(Boolean);
238
+ }
227
239
  const scopeVarsMap = /* @__PURE__ */ new Map();
228
240
  /**
229
241
  * Define global scope name to be used with `@InjectFromScope` and `@InjectScopeVars` decorators
@@ -639,6 +651,126 @@ function nextScopeId() {
639
651
  }
640
652
  }
641
653
 
654
+ //#endregion
655
+ //#region packages/moost/src/dispose.ts
656
+ /**
657
+ * Instances whose dispose hooks already ran. Module-level and weak, so
658
+ * disposal is at-most-once per instance no matter which entry point triggers
659
+ * it — `Moost.dispose()` twice, or a hot-reload eject followed by a shutdown,
660
+ * never re-runs a hook.
661
+ */ const disposedInstances = /* @__PURE__ */ new WeakSet();
662
+ const DISPOSE_BANNER = `moost`;
663
+ /**
664
+ * `Symbol.asyncDispose` / `Symbol.dispose` are only present on newer runtimes —
665
+ * resolve them once through a guarded read so older ones simply have no
666
+ * well-known fallback hook.
667
+ */ const asyncDisposeSymbol = typeof Symbol.asyncDispose === "symbol" ? Symbol.asyncDispose : void 0;
668
+ const disposeSymbol = typeof Symbol.dispose === "symbol" ? Symbol.dispose : void 0;
669
+ /** Class name of an instance, read through the prototype (never touches own props). */ function classNameOf(instance) {
670
+ return Object.getPrototypeOf(instance)?.constructor?.name || "Object";
671
+ }
672
+ /** @internal Message of a thrown value, whatever it is. */ function errorMessage(error) {
673
+ return error instanceof Error ? error.message : String(error);
674
+ }
675
+ /** `Class.method (message)` pairs for every failed hook. */ function formatDisposeErrors(errors) {
676
+ const list = errors.map((e) => `${classNameOf(e.instance)}.${String(e.method)} (${errorMessage(e.error)})`).join(", ");
677
+ return `[moost] ${errors.length} dispose hook(s) failed: ${list}`;
678
+ }
679
+ /**
680
+ * True when `sym` resolves to a data property holding a function anywhere on the
681
+ * instance/prototype chain. Descriptor based — accessors are never invoked.
682
+ */ function hasSymbolMethod(instance, sym) {
683
+ if (!sym) return false;
684
+ let obj = instance;
685
+ while (obj && obj !== Object.prototype) {
686
+ const desc = Object.getOwnPropertyDescriptor(obj, sym);
687
+ if (desc) return typeof desc.value === "function";
688
+ obj = Object.getPrototypeOf(obj);
689
+ }
690
+ return false;
691
+ }
692
+ /**
693
+ * Dispose hooks of one instance: every `@MoostDispose`-decorated method or —
694
+ * when the class decorates none — its `Symbol.asyncDispose` / `Symbol.dispose`
695
+ * method (preferring the async one) as a single priority-0 hook. The method
696
+ * scan goes through `getInstanceOwnMethods`, which classifies by descriptor and
697
+ * never evaluates getters.
698
+ */ function collectHooks(instance) {
699
+ const mate = getMoostMate();
700
+ const hooks = [];
701
+ for (const method of getInstanceOwnMethods(instance)) {
702
+ const priority = mate.read(instance, method)?.moostDispose?.priority;
703
+ if (typeof priority === "number") hooks.push({
704
+ instance,
705
+ method,
706
+ priority
707
+ });
708
+ }
709
+ if (hooks.length > 0) return hooks;
710
+ const sym = hasSymbolMethod(instance, asyncDisposeSymbol) ? asyncDisposeSymbol : hasSymbolMethod(instance, disposeSymbol) ? disposeSymbol : void 0;
711
+ return sym ? [{
712
+ instance,
713
+ method: sym,
714
+ priority: 0
715
+ }] : [];
716
+ }
717
+ async function runHook(hook, errors, opts) {
718
+ const fn = hook.instance[hook.method];
719
+ try {
720
+ await fn.call(hook.instance);
721
+ } catch (error) {
722
+ errors.push({
723
+ instance: hook.instance,
724
+ method: hook.method,
725
+ error
726
+ });
727
+ if (opts?.onError !== "throw") (opts?.logger || getDefaultLogger(DISPOSE_BANNER)).warn(`[moost] dispose hook ${classNameOf(hook.instance)}.${String(hook.method)} failed: ${errorMessage(error)}`);
728
+ }
729
+ }
730
+ /**
731
+ * Runs the dispose hooks of every given instance, once per instance, ever.
732
+ *
733
+ * A hook is either a `@MoostDispose()`-decorated method or, when the class
734
+ * decorates none, the instance's `[Symbol.asyncDispose]()` / `[Symbol.dispose]()`
735
+ * method. Hooks run **sequentially** in ascending `priority` (ties keep input
736
+ * order) and each one is awaited, so teardown that must happen in order (drain
737
+ * consumers, then close the connection they use) can be expressed with
738
+ * priorities.
739
+ *
740
+ * Failures never stop the run: with `onError: 'warn'` (default) each one is
741
+ * logged and collected; with `onError: 'throw'` every hook still runs and one
742
+ * `AggregateError` is thrown at the end.
743
+ *
744
+ * Instances already disposed (by an earlier call, or by `Moost.dispose()`) are
745
+ * skipped, so passing overlapping sets is safe.
746
+ */ async function disposeInstances(instances, opts) {
747
+ const hooks = [];
748
+ const disposed = [];
749
+ for (const instance of instances) {
750
+ if (!instance || disposedInstances.has(instance)) continue;
751
+ disposedInstances.add(instance);
752
+ const own = collectHooks(instance);
753
+ if (own.length > 0) {
754
+ disposed.push(instance);
755
+ hooks.push(...own);
756
+ }
757
+ }
758
+ const errors = [];
759
+ for (const hook of hooks.toSorted((a, b) => a.priority - b.priority)) await runHook(hook, errors, opts);
760
+ if (opts?.onError === "throw" && errors.length > 0) throw new AggregateError(errors.map((e) => e.error), formatDisposeErrors(errors));
761
+ return {
762
+ hooks: hooks.length,
763
+ errors,
764
+ disposed
765
+ };
766
+ }
767
+ /**
768
+ * @internal Renders the aggregate message used when disposal failures are
769
+ * rethrown (`Moost.dispose()` and `disposeInstances(..., { onError: 'throw' })`).
770
+ */ function describeDisposeErrors(errors) {
771
+ return formatDisposeErrors(errors);
772
+ }
773
+
642
774
  //#endregion
643
775
  //#region packages/moost/src/decorators/circular.decorator.ts
644
776
  /**
@@ -766,6 +898,56 @@ function ImportController(prefix, controller, provide) {
766
898
  }, true);
767
899
  }
768
900
 
901
+ //#endregion
902
+ //#region packages/moost/src/decorators/dispose.decorator.ts
903
+ /**
904
+ * ## MoostDispose
905
+ * ### @Decorator
906
+ * Marks a method to run **once**, when its instance is **disposed** — either by
907
+ * `Moost.dispose()` (graceful shutdown) or by the `@moostjs/vite` dev server
908
+ * when the instance is ejected on hot reload. It is the teardown counterpart of
909
+ * {@link MoostInit}: release what the singleton owns (a queue consumer, a cache
910
+ * client, a database handle, timers, watchers) so a replaced instance does not
911
+ * leak it.
912
+ *
913
+ * Works on SINGLETON controllers **and** on any `@Injectable()` singleton — the
914
+ * hooks are discovered from live instances at dispose time, so a provider that
915
+ * owns a connection is covered without being a controller.
916
+ *
917
+ * The method takes **no arguments** (dispose tears down an existing instance —
918
+ * inject whatever it needs in the constructor). Async methods are awaited.
919
+ * Errors are collected, not fatal: every other hook still runs, `Moost.dispose()`
920
+ * then rejects with an `AggregateError`, and a hot reload only warns.
921
+ *
922
+ * An instance is disposed at most once (tracked per instance), so a second
923
+ * `dispose()` — or an eject followed by a shutdown — never re-runs a hook.
924
+ *
925
+ * When a class defines no `@MoostDispose` method but exposes
926
+ * `[Symbol.asyncDispose]()` / `[Symbol.dispose]()`, that is used instead.
927
+ *
928
+ * Applying `@MoostDispose` to a `FOR_EVENT` **controller** is a configuration
929
+ * error and throws at bind time (per-event instances are torn down with their
930
+ * event — put the hook on the singleton that owns the resource). On a
931
+ * `FOR_EVENT` **injectable** the hook is simply never invoked.
932
+ *
933
+ * @param opts.priority lower runs first across all `@MoostDispose` methods (default 0)
934
+ *
935
+ * @example
936
+ * ```ts
937
+ * │ @Injectable()
938
+ * │ export class CacheClient {
939
+ * │ private readonly client = createCacheClient()
940
+ * │
941
+ * │ @MoostDispose()
942
+ * │ async close() {
943
+ * │ await this.client.quit()
944
+ * │ }
945
+ * │ }
946
+ * ```
947
+ */ function MoostDispose(opts) {
948
+ return getMoostMate().decorate("moostDispose", { priority: opts?.priority ?? 0 }, false);
949
+ }
950
+
769
951
  //#endregion
770
952
  //#region packages/moost/src/decorators/resolve.decorator.ts
771
953
  /**
@@ -1611,6 +1793,7 @@ async function bindControllerMethods(options) {
1611
1793
  className: classConstructor.name,
1612
1794
  methodName: method
1613
1795
  }));
1796
+ if (methodMeta.moostDispose && meta.injectable === "FOR_EVENT") throw new Error(`@MoostDispose is not allowed on a FOR_EVENT controller (${classConstructor.name}.${String(method)}). Dispose hooks run on the SINGLETON instance; FOR_EVENT instances are torn down with their event — put the hook on the singleton that owns the resource.`);
1614
1797
  if (methodMeta.moostInit) {
1615
1798
  if (meta.injectable === "FOR_EVENT") throw new Error(`@MoostInit is not allowed on a FOR_EVENT controller (${classConstructor.name}.${String(method)}). Init hooks run once at boot on the SINGLETON instance; FOR_EVENT controllers have no init-time instance.`);
1616
1799
  const initArgsPipes = (methodMeta.params || []).map((p) => ({
@@ -1768,6 +1951,67 @@ function lostCtorParamsMessage(input, ancestor, count) {
1768
1951
  return [auditRouteDrop(input), auditLostCtorParams(input)].filter((f) => f !== void 0);
1769
1952
  }
1770
1953
 
1954
+ //#endregion
1955
+ //#region packages/moost/src/dispose-signals.ts
1956
+ /**
1957
+ * Global key for the record above. Global (rather than module-scoped) for the
1958
+ * same reason the module-identity stamp is: two copies of moost, or the entry
1959
+ * re-executing under the `@moostjs/vite` dev server, must share one set of
1960
+ * process listeners instead of stacking one per app.
1961
+ */ const DISPOSE_SIGNALS_KEY = Symbol.for("moost:dispose-on-signals");
1962
+ /**
1963
+ * Removes every registered listener and forgets the record. A no-op for a record
1964
+ * that is no longer the live one (a stale unregister fn, or a signal that already
1965
+ * dropped it).
1966
+ */ function dropSignalsRecord(record) {
1967
+ const holder = globalThis;
1968
+ if (holder[DISPOSE_SIGNALS_KEY] !== record) return;
1969
+ for (const [signal, listener] of record.listeners) process.off(signal, listener);
1970
+ record.listeners.clear();
1971
+ delete holder[DISPOSE_SIGNALS_KEY];
1972
+ }
1973
+ /**
1974
+ * The one listener body per signal (NestJS `enableShutdownHooks` semantics):
1975
+ * unhook every listener first, dispose the newest app, then re-raise `signal`
1976
+ * so the process exits with Node's default status for it. Because the listeners
1977
+ * are already gone, a second signal while disposal runs gets Node's default
1978
+ * handling (exit `128 + n`) — an impatient Ctrl-C is never stuck.
1979
+ */ async function onSignal(signal) {
1980
+ const record = globalThis[DISPOSE_SIGNALS_KEY];
1981
+ if (!record) return;
1982
+ const { app } = record;
1983
+ dropSignalsRecord(record);
1984
+ try {
1985
+ await app.dispose();
1986
+ } catch (error) {
1987
+ app.getLogger().warn(`[moost] dispose on ${signal} failed: ${errorMessage(error)}`);
1988
+ }
1989
+ process.kill(process.pid, signal);
1990
+ }
1991
+ /**
1992
+ * @internal Backs `Moost.disposeOnSignals()` — see that method's JSDoc for the
1993
+ * full contract. Registers at most one process listener per signal, ever, and
1994
+ * re-targets the existing ones at `app`.
1995
+ */ function registerDisposeOnSignals(app, signals) {
1996
+ var _holder, _DISPOSE_SIGNALS_KEY;
1997
+ const record = (_holder = globalThis)[_DISPOSE_SIGNALS_KEY = DISPOSE_SIGNALS_KEY] ?? (_holder[_DISPOSE_SIGNALS_KEY] = {
1998
+ app,
1999
+ listeners: /* @__PURE__ */ new Map()
2000
+ });
2001
+ record.app = app;
2002
+ for (const signal of signals) {
2003
+ if (record.listeners.has(signal)) continue;
2004
+ const listener = () => {
2005
+ onSignal(signal);
2006
+ };
2007
+ record.listeners.set(signal, listener);
2008
+ process.on(signal, listener);
2009
+ }
2010
+ return () => {
2011
+ dropSignalsRecord(record);
2012
+ };
2013
+ }
2014
+
1771
2015
  //#endregion
1772
2016
  //#region packages/moost/src/moost.ts
1773
2017
  function _define_property(obj, key, value) {
@@ -1931,6 +2175,84 @@ function _define_property(obj, key, value) {
1931
2175
  });
1932
2176
  }
1933
2177
  /**
2178
+ * ### dispose
2179
+ * Graceful shutdown: stops the adapters, then runs every `@MoostDispose`
2180
+ * hook of every singleton this app can reach — DI registry singletons
2181
+ * (`@Injectable()` providers included) plus the controller instances bound at
2182
+ * boot. A class with no decorated hook but an `[Symbol.asyncDispose]()` /
2183
+ * `[Symbol.dispose]()` method is disposed through that.
2184
+ *
2185
+ * Order: every adapter's `onDispose` in registration order (stop taking new
2186
+ * work first), then instance hooks by ascending `priority`. Everything is
2187
+ * awaited, failures never stop the run — once all hooks have run, a failed
2188
+ * disposal rejects the returned promise with an `AggregateError` naming each
2189
+ * failing `Class.method`.
2190
+ *
2191
+ * Idempotent: the promise of the first call is returned for every later one,
2192
+ * and an instance already disposed (e.g. ejected by the `@moostjs/vite` dev
2193
+ * server) is skipped.
2194
+ *
2195
+ * DI registries and metadata caches are left intact — use the existing
2196
+ * cleanup helpers when a clean container is what you need.
2197
+ *
2198
+ * ```ts
2199
+ * app.disposeOnSignals() // SIGTERM + SIGINT → dispose() → re-raise
2200
+ * ```
2201
+ */ dispose() {
2202
+ if (!this.disposePromise) this.disposePromise = this.runDispose();
2203
+ return this.disposePromise;
2204
+ }
2205
+ /**
2206
+ * ### disposeOnSignals
2207
+ * Disposes this app when the process receives one of `signals` (default
2208
+ * `['SIGTERM', 'SIGINT']`), then re-raises the signal with no listener left,
2209
+ * so the process exits with Node's default status for it.
2210
+ *
2211
+ * Registers each process listener **once per process**: calling it again —
2212
+ * e.g. the entry re-executing under the `@moostjs/vite` dev server — re-targets
2213
+ * the existing listeners at the newest app instead of stacking one handler per
2214
+ * old app (which is what a plain `process.once(...)` in the entry does, until
2215
+ * Node warns about a listener leak). Signals are unioned across calls; an
2216
+ * already-registered one is never replaced or removed.
2217
+ *
2218
+ * A second signal while disposal runs gets Node's default handling, since the
2219
+ * listeners are already removed. A failing `dispose()` is logged as a warning
2220
+ * and the signal is re-raised all the same.
2221
+ *
2222
+ * Returns a function that unregisters the listeners (tests, embedded runners).
2223
+ *
2224
+ * ```ts
2225
+ * await app.init()
2226
+ * app.disposeOnSignals()
2227
+ * ```
2228
+ */ disposeOnSignals(signals = ["SIGTERM", "SIGINT"]) {
2229
+ return registerDisposeOnSignals(this, signals);
2230
+ }
2231
+ /** Adapters' `onDispose` in registration order; errors are collected, not fatal. */ async disposeAdapters() {
2232
+ const errors = [];
2233
+ for (const a of this.adapters) try {
2234
+ await a.onDispose?.(this);
2235
+ } catch (error) {
2236
+ errors.push({
2237
+ instance: a,
2238
+ method: "onDispose",
2239
+ error
2240
+ });
2241
+ this.logger.warn(`[moost] adapter "${a.name}" onDispose failed: ${errorMessage(error)}`);
2242
+ }
2243
+ return errors;
2244
+ }
2245
+ /** The one-shot body behind {@link Moost.dispose} (see its docs for the contract). */ async runDispose() {
2246
+ const errors = await this.disposeAdapters();
2247
+ const result = await disposeInstances([...getInfactSingletonInstances(), ...this.singletonInstances], {
2248
+ logger: this.logger,
2249
+ onError: "warn"
2250
+ });
2251
+ errors.push(...result.errors);
2252
+ this.logger.debug(`[moost] disposed: ${this.adapters.length} adapter(s), ${result.hooks} hook(s), ${errors.length} error(s)`);
2253
+ if (errors.length > 0) throw new AggregateError(errors.map((e) => e.error), describeDisposeErrors(errors));
2254
+ }
2255
+ /**
1934
2256
  * D1 audit of a DI-instantiated controller class' constructor params.
1935
2257
  * Findings are collected for `init()` to flush. Returns `true` when the
1936
2258
  * bind-time SINGLETON instantiation must be skipped: in `'error'` mode a
@@ -1989,6 +2311,7 @@ function _define_property(obj, key, value) {
1989
2311
  instance = controller;
1990
2312
  infact.setInstanceRegistries(instance, provide, replace, { pipes });
1991
2313
  }
2314
+ if (instance) this.singletonInstances.add(instance);
1992
2315
  const getInstance = instance ? () => instance : async () => await infact.get(controller, { ...infactOpts });
1993
2316
  const classConstructor = isConstructor$1(controller) ? controller : getConstructor$1(controller);
1994
2317
  const controllerOverview = await bindControllerMethods({
@@ -2164,7 +2487,7 @@ function _define_property(obj, key, value) {
2164
2487
  this.logger.info(`${prefix || ""}${c}${eventName} ${"\x1B[0m\x1B[2m\x1B[32m" + c}→ ${classConstructor.name}.${"\x1B[36m" + c}${method}()${coff}`);
2165
2488
  }
2166
2489
  constructor(options) {
2167
- super(), _define_property(this, "options", void 0), _define_property(this, "logger", void 0), _define_property(this, "pipes", void 0), _define_property(this, "interceptors", void 0), _define_property(this, "adapters", void 0), _define_property(this, "controllersOverview", void 0), _define_property(this, "handlerOverviewIndex", void 0), _define_property(this, "initHooks", void 0), _define_property(this, "provide", void 0), _define_property(this, "replace", void 0), _define_property(this, "unregisteredControllers", void 0), _define_property(this, "paramAuditFindings", void 0), _define_property(this, "paramAuditMode", void 0), _define_property(this, "inheritanceAuditMode", void 0), _define_property(this, "initialized", void 0), _define_property(this, "globalInterceptorHandler", void 0), this.options = options, this.pipes = Array.from(sharedPipes), this.interceptors = [], this.adapters = [], this.controllersOverview = [], this.initHooks = [], this.provide = createProvideRegistry$1([Infact, getMoostInfact], [Mate, getMoostMate]), this.replace = {}, this.unregisteredControllers = [], this.paramAuditFindings = [], this.initialized = false;
2490
+ super(), _define_property(this, "options", void 0), _define_property(this, "logger", void 0), _define_property(this, "pipes", void 0), _define_property(this, "interceptors", void 0), _define_property(this, "adapters", void 0), _define_property(this, "controllersOverview", void 0), _define_property(this, "handlerOverviewIndex", void 0), _define_property(this, "initHooks", void 0), _define_property(this, "provide", void 0), _define_property(this, "replace", void 0), _define_property(this, "unregisteredControllers", void 0), _define_property(this, "paramAuditFindings", void 0), _define_property(this, "paramAuditMode", void 0), _define_property(this, "inheritanceAuditMode", void 0), _define_property(this, "initialized", void 0), _define_property(this, "singletonInstances", void 0), _define_property(this, "disposePromise", void 0), _define_property(this, "globalInterceptorHandler", void 0), this.options = options, this.pipes = Array.from(sharedPipes), this.interceptors = [], this.adapters = [], this.controllersOverview = [], this.initHooks = [], this.provide = createProvideRegistry$1([Infact, getMoostInfact], [Mate, getMoostMate]), this.replace = {}, this.unregisteredControllers = [], this.paramAuditFindings = [], this.initialized = false, this.singletonInstances = /* @__PURE__ */ new Set();
2168
2491
  this.paramAuditMode = resolveParamAuditMode(options?.diagnostics?.paramTypes);
2169
2492
  this.inheritanceAuditMode = options?.diagnostics?.inheritance || "warn";
2170
2493
  this.logger = options?.logger || getDefaultLogger(`moost`);
@@ -2242,4 +2565,4 @@ function _define_property(obj, key, value) {
2242
2565
  }
2243
2566
 
2244
2567
  //#endregion
2245
- export { After, ApplyDecorators, Before, Circular, Const, ConstFactory, ContextInjector, Controller, Description, HandlerPaths, Id, ImportController, Inherit, Inject, InjectEventLogger, InjectFromScope, InjectMoost, InjectMoostLogger, InjectScopeVars, Injectable, Intercept, Interceptor, InterceptorHandler, Label, LoggerTopic, MOOST_MODULE_IDENTITY_KEY, Moost, MoostInit, OnError, Optional, Overtake, Param, Params, Pipe, ProstoLogger, Provide, Replace, Required, Resolve, Response, TInterceptorPriority, TPipePriority, Value, cached, clearGlobalWooks, createEventContext, createLogger, createProvideRegistry, createReplaceRegistry, current, defineAfterInterceptor, defineBeforeInterceptor, defineErrorInterceptor, defineInfactScope, defineInterceptor, defineMoostEventHandler, definePipeFn, eventTypeKey, getConstructor, getContextInjector, getGlobalWooks, getHandlerPaths, getInfactScopeVars, getInstanceOwnMethods, getInstanceOwnProps, getMoostInfact, getMoostMate, getNewMoostInfact, globalKey, isConstructor, isThenable, key, loggerConsoleTransport, mergeSorted, registerEventScope, replaceContextInjector, resetContextInjector, resolvePipe, run, setControllerContext, setInfactLoggingOptions, setInterceptResult, setOvertake, stampModuleIdentity, stampOnce, useControllerContext, useHandlerPaths, useInterceptResult, useLogger, useOvertake, useScopeId };
2568
+ export { After, ApplyDecorators, Before, Circular, Const, ConstFactory, ContextInjector, Controller, Description, HandlerPaths, Id, ImportController, Inherit, Inject, InjectEventLogger, InjectFromScope, InjectMoost, InjectMoostLogger, InjectScopeVars, Injectable, Intercept, Interceptor, InterceptorHandler, Label, LoggerTopic, MOOST_MODULE_IDENTITY_KEY, Moost, MoostDispose, MoostInit, OnError, Optional, Overtake, Param, Params, Pipe, ProstoLogger, Provide, Replace, Required, Resolve, Response, TInterceptorPriority, TPipePriority, Value, cached, clearGlobalWooks, createEventContext, createLogger, createProvideRegistry, createReplaceRegistry, current, defineAfterInterceptor, defineBeforeInterceptor, defineErrorInterceptor, defineInfactScope, defineInterceptor, defineMoostEventHandler, definePipeFn, disposeInstances, eventTypeKey, getConstructor, getContextInjector, getGlobalWooks, getHandlerPaths, getInfactScopeVars, getInfactSingletonInstances, getInstanceOwnMethods, getInstanceOwnProps, getMoostInfact, getMoostMate, getNewMoostInfact, globalKey, isConstructor, isThenable, key, loggerConsoleTransport, mergeSorted, registerEventScope, replaceContextInjector, resetContextInjector, resolvePipe, run, setControllerContext, setInfactLoggingOptions, setInterceptResult, setOvertake, stampModuleIdentity, stampOnce, useControllerContext, useHandlerPaths, useInterceptResult, useLogger, useOvertake, useScopeId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moost",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
4
4
  "description": "moost",
5
5
  "keywords": [
6
6
  "composables",
@@ -41,15 +41,15 @@
41
41
  "@prostojs/infact": "^0.5.0",
42
42
  "@prostojs/logger": "^0.4.3",
43
43
  "@prostojs/mate": "^0.5.0",
44
- "@wooksjs/event-core": "^0.7.22",
44
+ "@wooksjs/event-core": "^0.7.23",
45
45
  "hookable": "^5.5.3",
46
- "wooks": "^0.7.22"
46
+ "wooks": "^0.7.23"
47
47
  },
48
48
  "devDependencies": {
49
- "@wooksjs/event-http": "^0.7.22",
50
- "@wooksjs/http-body": "^0.7.22",
49
+ "@wooksjs/event-http": "^0.7.23",
50
+ "@wooksjs/http-body": "^0.7.23",
51
51
  "vitest": "3.2.7",
52
- "@moostjs/event-http": "^0.6.35"
52
+ "@moostjs/event-http": "^0.6.37"
53
53
  },
54
54
  "scripts": {
55
55
  "pub": "pnpm publish --access public",