@actuallab/core 13.0.4 → 13.0.14

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.d.ts CHANGED
@@ -171,8 +171,9 @@ declare class EventHandlerSet<T> {
171
171
  }
172
172
 
173
173
  /** Externally-resolvable promise — similar to .NET's TaskCompletionSource<T>. */
174
- declare class PromiseSource<T> {
174
+ declare class PromiseSource<T> implements Promise<T> {
175
175
  readonly promise: Promise<T>;
176
+ readonly [Symbol.toStringTag] = "Promise";
176
177
  private _resolve;
177
178
  private _reject;
178
179
  private _isCompleted;
@@ -180,9 +181,139 @@ declare class PromiseSource<T> {
180
181
  get isCompleted(): boolean;
181
182
  resolve(value: T): boolean;
182
183
  reject(reason?: unknown): boolean;
184
+ then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null): Promise<TResult1 | TResult2>;
185
+ catch<TResult = never>(onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null): Promise<T | TResult>;
186
+ finally(onfinally?: (() => void) | null): Promise<T>;
183
187
  }
184
188
  declare const resolvedVoidPromise: Promise<void>;
185
189
 
190
+ /**
191
+ * A {@link PromiseSource} with a built-in timer that can reject (or run a
192
+ * caller-supplied callback) after a configurable delay. The timer is
193
+ * automatically cleared when the promise settles by other means, so callers
194
+ * don't have to remember to `clearTimeout()` on every success path.
195
+ *
196
+ * Typical use:
197
+ * ```ts
198
+ * const ps = new PromiseSourceWithTimeout<string>();
199
+ * ps.setTimeout(5_000); // rejects after 5s if still pending
200
+ * ps.setTimeout(5_000, () => ps.resolve('fallback')); // or run a custom callback
201
+ * ```
202
+ *
203
+ * Replaces the `PromiseSourceWithTimeout` previously kept in
204
+ * `ActualChat/src/nodejs/src/promises.ts`. Note: the Voxt original supported a
205
+ * "precise" (requestAnimationFrame-based) variant; we drop it here because no
206
+ * caller in ActualChat uses it outside `promises.ts` itself.
207
+ */
208
+ declare class PromiseSourceWithTimeout<T> extends PromiseSource<T> {
209
+ private _timer;
210
+ hasTimeout(): boolean;
211
+ /**
212
+ * Install (or replace) a timeout. Pass `null` to remove without setting a
213
+ * new one. `callback` is invoked when the timer fires — if omitted, the
214
+ * promise is rejected with a generic "timed out" error.
215
+ */
216
+ setTimeout(ms: number | null, callback?: () => unknown): void;
217
+ clearTimeout(): void;
218
+ resolve(value: T): boolean;
219
+ reject(reason?: unknown): boolean;
220
+ }
221
+
222
+ /**
223
+ * Pre-resolved promises for the three values most often returned as a
224
+ * "no-op" successful path: `void`, `true`, `false`. Returning these
225
+ * avoids allocating a fresh promise per call site.
226
+ *
227
+ * Idiomatic use:
228
+ * ```ts
229
+ * if (alreadyInitialized) return ResolvedPromise.Void;
230
+ * ```
231
+ */
232
+ declare const ResolvedPromise: {
233
+ readonly Void: Promise<void>;
234
+ readonly True: Promise<boolean>;
235
+ readonly False: Promise<boolean>;
236
+ };
237
+
238
+ /**
239
+ * Sentinel returned/thrown by code that distinguishes "the operation took
240
+ * too long" from a generic rejection. Identity-comparable via the
241
+ * `instance` singleton — callers can branch on `result === TimedOut.instance`.
242
+ */
243
+ declare class TimedOut {
244
+ static readonly instance: TimedOut;
245
+ }
246
+
247
+ /** Error type rejected by {@link withTimeout} when its budget elapses. */
248
+ declare class TimeoutError extends Error {
249
+ readonly name = "TimeoutError";
250
+ constructor(message?: string);
251
+ }
252
+ /**
253
+ * Race `promise` against a `ms`-millisecond timer. Resolves with the inner
254
+ * promise's value if it settles first; otherwise rejects with a
255
+ * {@link TimeoutError} carrying `message`. The timer is always cleared in
256
+ * a `finally`, so it cannot keep the event loop alive after the race ends.
257
+ *
258
+ * The inner promise's rejection (and the timeout's) are routed through
259
+ * `Promise.race`, so callers should `await` the result inside a `try/catch`
260
+ * exactly as they would for a single awaited promise. There's no
261
+ * "unhandled rejection" hazard from the timeout branch — the race always
262
+ * observes whichever promise wins, and the loser is allowed to settle
263
+ * silently into the void.
264
+ */
265
+ declare function withTimeout<T>(promise: PromiseLike<T>, ms: number, message?: string): Promise<T>;
266
+
267
+ /** Resolves after `ms` milliseconds. */
268
+ declare function delayAsync(ms: number): Promise<void>;
269
+ /** Resolves with `value` after `ms` milliseconds. */
270
+ declare function delayAsyncWith<T>(ms: number, value: T): Promise<T>;
271
+
272
+ declare function abortPromise(signal: AbortSignal): Promise<never>;
273
+
274
+ /** A function wrapped by {@link throttle} or {@link debounce}. Calling it
275
+ * triggers the underlying function according to the scheduling rule;
276
+ * `reset()` discards any pending scheduled call. */
277
+ interface ResettableFunc<TArgs extends unknown[]> {
278
+ (...args: TArgs): void;
279
+ reset(): void;
280
+ }
281
+ /** Throttle modes:
282
+ * - `default` — fire the head call immediately, then drop further calls
283
+ * until the interval elapses; the *last* one is fired at interval end.
284
+ * - `skip` — fire the head call immediately, drop ALL calls during the
285
+ * interval (no tail call).
286
+ * - `delayHead` — delay even the head call by one interval; subsequent
287
+ * calls overwrite the pending one. */
288
+ type ThrottleMode = 'default' | 'skip' | 'delayHead';
289
+ /**
290
+ * Throttle calls to `func` so that it fires at most once every `intervalMs`.
291
+ * The returned function has a `.reset()` that discards any pending tail call
292
+ * and re-arms the head-call behavior on the next invocation.
293
+ */
294
+ declare function throttle<TArgs extends unknown[]>(func: (...args: TArgs) => unknown, intervalMs: number, mode?: ThrottleMode): ResettableFunc<TArgs>;
295
+ /**
296
+ * Debounce calls to `func`: the function fires only after `intervalMs` have
297
+ * elapsed since the LAST call. Each new call resets the timer. `.reset()`
298
+ * discards any pending fire without invoking `func`.
299
+ */
300
+ declare function debounce<TArgs extends unknown[]>(func: (...args: TArgs) => unknown, intervalMs: number): ResettableFunc<TArgs>;
301
+
302
+ /**
303
+ * Wrap an async function so concurrent invocations are serialized into a
304
+ * single chain — each call awaits the previous one before running, and the
305
+ * caller receives the result of *its* own invocation.
306
+ *
307
+ * Useful for stateful async operations (DB writes, file I/O, network I/O
308
+ * that must not overlap) where the underlying contract is "one at a time."
309
+ *
310
+ * If `limit` is set and the queue is already that deep, the call short-
311
+ * circuits and returns the same promise the most recent queued call returned
312
+ * — i.e. it coalesces excess pressure onto the trailing in-flight call.
313
+ * Pass `null` (the default) for unbounded queueing.
314
+ */
315
+ declare function serialize<TArgs extends unknown[], TResult>(func: (...args: TArgs) => PromiseLike<TResult> | TResult, limit?: number | null): (...args: TArgs) => Promise<TResult>;
316
+
186
317
  /**
187
318
  * A fixed-capacity circular buffer supporting efficient push/pull at both
188
319
  * head and tail.
@@ -250,4 +381,29 @@ declare class RetryDelayer {
250
381
  cancelDelays(): void;
251
382
  }
252
383
 
253
- export { AsyncContext, AsyncContextKey, type AsyncDisposable, AsyncLock, type Disposable, DisposableBag, EventHandlerSet, type IResult, Log, LogLevel, LogLevelController, type LogRef, type LogScopeFns, PromiseSource, Result, type RetryDelay, RetryDelayLimitExceeded, RetryDelayNone, RetryDelaySeq, RetryDelayer, RingBuffer, abortSignalKey, createLogProvider, errorResult, initLogging, initWorkerLogging, resolvedVoidPromise, result, resultFrom, resultFromAsync };
384
+ /** A sequence of retry delays — given a 1-based try index (1 = first
385
+ * retry, i.e. AFTER the initial failure), returns the delay in ms before
386
+ * the next attempt. For more elaborate delay schemes, see
387
+ * {@link RetryDelaySeq.getDelay}. */
388
+ type RetryDelaySchedule = (tryIndex: number) => number;
389
+ /**
390
+ * Run `fn` up to `tryCount` times, awaiting a delay between attempts.
391
+ * The last error is rethrown if every attempt fails.
392
+ *
393
+ * `fn` is passed the 0-based attempt index and the last error (or
394
+ * `undefined` for the first attempt) — useful for adapting behavior per
395
+ * try (e.g. switching endpoints, escalating timeouts).
396
+ */
397
+ declare function retry<TResult>(tryCount: number, fn: (tryIndex: number, lastError: unknown) => PromiseLike<TResult> | TResult, retryDelays?: RetryDelaySchedule): Promise<TResult>;
398
+ /**
399
+ * Run `fn` and swallow any throw, returning `onError(e)` if provided or
400
+ * `undefined` otherwise. The returned promise always settles successfully
401
+ * — the caller never has to `try/catch` it.
402
+ *
403
+ * Useful where a logical "best-effort" return is wanted but the runtime
404
+ * defaults — unhandled rejection warnings, exception escape past finally
405
+ * blocks — are not.
406
+ */
407
+ declare function catchErrors<TResult>(fn: () => PromiseLike<TResult> | TResult, onError?: (e: unknown) => TResult): Promise<TResult | undefined>;
408
+
409
+ export { AsyncContext, AsyncContextKey, type AsyncDisposable, AsyncLock, type Disposable, DisposableBag, EventHandlerSet, type IResult, Log, LogLevel, LogLevelController, type LogRef, type LogScopeFns, PromiseSource, PromiseSourceWithTimeout, type ResettableFunc, ResolvedPromise, Result, type RetryDelay, RetryDelayLimitExceeded, RetryDelayNone, type RetryDelaySchedule, RetryDelaySeq, RetryDelayer, RingBuffer, type ThrottleMode, TimedOut, TimeoutError, abortPromise, abortSignalKey, catchErrors, createLogProvider, debounce, delayAsync, delayAsyncWith, errorResult, initLogging, initWorkerLogging, resolvedVoidPromise, result, resultFrom, resultFromAsync, retry, serialize, throttle, withTimeout };
package/dist/index.js CHANGED
@@ -593,6 +593,7 @@ var AsyncLock = class {
593
593
  // src/promise-source.ts
594
594
  var PromiseSource = class {
595
595
  promise;
596
+ [Symbol.toStringTag] = "Promise";
596
597
  _resolve;
597
598
  _reject;
598
599
  _isCompleted = false;
@@ -617,6 +618,15 @@ var PromiseSource = class {
617
618
  this._reject(reason);
618
619
  return true;
619
620
  }
621
+ then(onfulfilled, onrejected) {
622
+ return this.promise.then(onfulfilled, onrejected);
623
+ }
624
+ catch(onrejected) {
625
+ return this.promise.catch(onrejected);
626
+ }
627
+ finally(onfinally) {
628
+ return this.promise.finally(onfinally);
629
+ }
620
630
  };
621
631
  var resolvedVoidPromise = Promise.resolve();
622
632
 
@@ -645,10 +655,235 @@ var EventHandlerSet = class {
645
655
  ps.resolve(arg);
646
656
  };
647
657
  this._handlers.add(handler);
648
- return ps.promise;
658
+ return ps;
659
+ }
660
+ };
661
+
662
+ // src/withTimeout.ts
663
+ var TimeoutError = class extends Error {
664
+ name = "TimeoutError";
665
+ constructor(message = "Operation timed out") {
666
+ super(message);
667
+ }
668
+ };
669
+ async function withTimeout(promise, ms, message) {
670
+ let timer;
671
+ const timeoutPromise = new Promise((_, reject) => {
672
+ timer = setTimeout(
673
+ () => reject(new TimeoutError(message ?? `Operation timed out after ${ms}ms`)),
674
+ ms
675
+ );
676
+ });
677
+ timeoutPromise.catch(() => {
678
+ });
679
+ try {
680
+ return await Promise.race([promise, timeoutPromise]);
681
+ } finally {
682
+ if (timer !== void 0) clearTimeout(timer);
683
+ }
684
+ }
685
+
686
+ // src/promise-source-with-timeout.ts
687
+ var PromiseSourceWithTimeout = class extends PromiseSource {
688
+ _timer;
689
+ hasTimeout() {
690
+ return this._timer !== void 0;
691
+ }
692
+ /**
693
+ * Install (or replace) a timeout. Pass `null` to remove without setting a
694
+ * new one. `callback` is invoked when the timer fires — if omitted, the
695
+ * promise is rejected with a generic "timed out" error.
696
+ */
697
+ setTimeout(ms, callback) {
698
+ this.clearTimeout();
699
+ if (ms === null || this.isCompleted) return;
700
+ this._timer = setTimeout(() => {
701
+ this._timer = void 0;
702
+ if (callback) callback();
703
+ else this.reject(new TimeoutError(`The promise has timed out after ${ms}ms.`));
704
+ }, ms);
705
+ }
706
+ clearTimeout() {
707
+ if (this._timer === void 0) return;
708
+ clearTimeout(this._timer);
709
+ this._timer = void 0;
710
+ }
711
+ resolve(value) {
712
+ const didSettle = super.resolve(value);
713
+ if (didSettle) this.clearTimeout();
714
+ return didSettle;
715
+ }
716
+ reject(reason) {
717
+ const didSettle = super.reject(reason);
718
+ if (didSettle) this.clearTimeout();
719
+ return didSettle;
649
720
  }
650
721
  };
651
722
 
723
+ // src/resolved-promise.ts
724
+ var ResolvedPromise = {
725
+ Void: Promise.resolve(),
726
+ True: Promise.resolve(true),
727
+ False: Promise.resolve(false)
728
+ };
729
+
730
+ // src/timed-out.ts
731
+ var TimedOut = class _TimedOut {
732
+ static instance = new _TimedOut();
733
+ };
734
+
735
+ // src/delay.ts
736
+ function delayAsync(ms) {
737
+ return new Promise((resolve) => {
738
+ setTimeout(resolve, ms);
739
+ });
740
+ }
741
+ function delayAsyncWith(ms, value) {
742
+ return new Promise((resolve) => {
743
+ setTimeout(() => resolve(value), ms);
744
+ });
745
+ }
746
+
747
+ // src/abort-promise.ts
748
+ var abortPromiseCache = /* @__PURE__ */ new WeakMap();
749
+ function abortPromise(signal) {
750
+ if (signal.aborted) return Promise.reject(signal.reason);
751
+ let cached = abortPromiseCache.get(signal);
752
+ if (!cached) {
753
+ cached = new Promise((_, reject) => {
754
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
755
+ });
756
+ cached.catch(() => {
757
+ });
758
+ abortPromiseCache.set(signal, cached);
759
+ }
760
+ return cached;
761
+ }
762
+
763
+ // src/internal-logging.ts
764
+ var scopeDefaults = {
765
+ retry: 3 /* Warn */,
766
+ throttle: 3 /* Warn */
767
+ };
768
+ var getLogs = createLogProvider("core.", scopeDefaults);
769
+
770
+ // src/throttle.ts
771
+ var { errorLog } = getLogs("throttle");
772
+ var Call = class {
773
+ constructor(func, self, parameters) {
774
+ this.func = func;
775
+ this.self = self;
776
+ this.parameters = parameters;
777
+ }
778
+ invoke() {
779
+ return this.func.apply(this.self, this.parameters);
780
+ }
781
+ invokeSilently() {
782
+ try {
783
+ return this.invoke();
784
+ } catch (error) {
785
+ errorLog?.log("Call.invokeSilently: unhandled error:", error);
786
+ }
787
+ }
788
+ };
789
+ function throttle(func, intervalMs, mode = "default") {
790
+ let lastCall = null;
791
+ let nextFireTime = 0;
792
+ let timeoutHandle = null;
793
+ const reset2 = () => {
794
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
795
+ timeoutHandle = null;
796
+ lastCall = null;
797
+ nextFireTime = 0;
798
+ };
799
+ const fire = () => {
800
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
801
+ if (lastCall !== null) {
802
+ const call = lastCall;
803
+ lastCall = null;
804
+ nextFireTime = Date.now() + intervalMs;
805
+ timeoutHandle = setTimeout(fire, intervalMs);
806
+ call.invokeSilently();
807
+ } else {
808
+ timeoutHandle = null;
809
+ nextFireTime = 0;
810
+ }
811
+ };
812
+ const wrapped = function(...args) {
813
+ const call = new Call(func, this, args);
814
+ const fireDelay = nextFireTime - Date.now();
815
+ if (timeoutHandle !== null && fireDelay <= 0) {
816
+ fire();
817
+ }
818
+ if (timeoutHandle === null) {
819
+ nextFireTime = Date.now() + intervalMs;
820
+ timeoutHandle = setTimeout(fire, intervalMs);
821
+ if (mode === "delayHead") {
822
+ lastCall = call;
823
+ } else {
824
+ call.invokeSilently();
825
+ }
826
+ } else if (mode !== "skip") {
827
+ lastCall = call;
828
+ }
829
+ };
830
+ wrapped.reset = reset2;
831
+ return wrapped;
832
+ }
833
+ function debounce(func, intervalMs) {
834
+ let lastCall = null;
835
+ let nextFireTime = 0;
836
+ let timeoutHandle = null;
837
+ const reset2 = () => {
838
+ timeoutHandle = null;
839
+ lastCall = null;
840
+ nextFireTime = 0;
841
+ };
842
+ const fire = () => {
843
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
844
+ timeoutHandle = null;
845
+ const fireDelay = nextFireTime - Date.now();
846
+ if (fireDelay <= 0) {
847
+ nextFireTime = 0;
848
+ if (lastCall !== null) {
849
+ const call = lastCall;
850
+ lastCall = null;
851
+ call.invokeSilently();
852
+ }
853
+ } else {
854
+ timeoutHandle = setTimeout(fire, fireDelay);
855
+ }
856
+ };
857
+ const wrapped = function(...args) {
858
+ lastCall = new Call(func, this, args);
859
+ nextFireTime = Date.now() + intervalMs;
860
+ timeoutHandle ??= setTimeout(fire, intervalMs);
861
+ };
862
+ wrapped.reset = reset2;
863
+ return wrapped;
864
+ }
865
+
866
+ // src/serialize.ts
867
+ function serialize(func, limit = null) {
868
+ let lastCall = Promise.resolve(null);
869
+ let queueSize = 0;
870
+ return function(...args) {
871
+ if (limit !== null && queueSize >= limit) return lastCall;
872
+ queueSize++;
873
+ const prevCall = lastCall;
874
+ const invoke = () => func.apply(this, args);
875
+ return lastCall = (async () => {
876
+ try {
877
+ await prevCall.catch(() => {
878
+ });
879
+ return await invoke();
880
+ } finally {
881
+ queueSize--;
882
+ }
883
+ })();
884
+ };
885
+ }
886
+
652
887
  // src/ring-buffer.ts
653
888
  var RingBuffer = class {
654
889
  _buffer;
@@ -839,6 +1074,34 @@ var RetryDelayer = class {
839
1074
  this.cancelDelaysChanged.trigger();
840
1075
  }
841
1076
  };
1077
+
1078
+ // src/retry.ts
1079
+ var { warnLog } = getLogs("retry");
1080
+ var defaultRetryDelays = () => 50;
1081
+ async function retry(tryCount, fn, retryDelays = defaultRetryDelays) {
1082
+ if (tryCount <= 0)
1083
+ throw new Error("retry: tryCount must be positive.");
1084
+ let lastError;
1085
+ for (let tryIndex = 0; ; ) {
1086
+ if (tryIndex >= tryCount) throw lastError;
1087
+ try {
1088
+ return await fn(tryIndex, lastError);
1089
+ } catch (e) {
1090
+ lastError = e;
1091
+ }
1092
+ ++tryIndex;
1093
+ if (tryIndex >= tryCount) throw lastError;
1094
+ warnLog?.log(`retry(${tryIndex}/${tryCount}): error:`, lastError);
1095
+ await delayAsync(retryDelays(tryIndex));
1096
+ }
1097
+ }
1098
+ async function catchErrors(fn, onError) {
1099
+ try {
1100
+ return await fn();
1101
+ } catch (e) {
1102
+ return onError ? onError(e) : void 0;
1103
+ }
1104
+ }
842
1105
  export {
843
1106
  AsyncContext,
844
1107
  AsyncContextKey,
@@ -849,20 +1112,33 @@ export {
849
1112
  LogLevel,
850
1113
  LogLevelController,
851
1114
  PromiseSource,
1115
+ PromiseSourceWithTimeout,
1116
+ ResolvedPromise,
852
1117
  Result,
853
1118
  RetryDelayLimitExceeded,
854
1119
  RetryDelayNone,
855
1120
  RetryDelaySeq,
856
1121
  RetryDelayer,
857
1122
  RingBuffer,
1123
+ TimedOut,
1124
+ TimeoutError,
1125
+ abortPromise,
858
1126
  abortSignalKey,
1127
+ catchErrors,
859
1128
  createLogProvider,
1129
+ debounce,
1130
+ delayAsync,
1131
+ delayAsyncWith,
860
1132
  errorResult,
861
1133
  initLogging,
862
1134
  initWorkerLogging,
863
1135
  resolvedVoidPromise,
864
1136
  result,
865
1137
  resultFrom,
866
- resultFromAsync
1138
+ resultFromAsync,
1139
+ retry,
1140
+ serialize,
1141
+ throttle,
1142
+ withTimeout
867
1143
  };
868
1144
  //# sourceMappingURL=index.js.map