Skip to content

@actuallab/core

Core primitives used across all other Fusion TypeScript packages. This package has zero dependencies and corresponds to parts of ActualLab.Core in .NET.

Result<T>

Immutable value-or-error container — the TypeScript equivalent of Result<T> in .NET.

ts
import { Result, result, errorResult, resultFrom, resultFromAsync } from "@actuallab/core";

const ok = result(42);            // Result<number> with value
const err = errorResult<number>(new Error("fail"));  // Result<number> with error

ok.hasValue;        // true
ok.value;           // 42
ok.error;           // undefined
ok.valueOrUndefined // 42

err.hasError;       // true
err.error;          // Error("fail")
err.value;          // throws Error("fail")

// Wrap a function call in a Result
const r1 = resultFrom(() => JSON.parse("{}"));          // Result<any>
const r2 = await resultFromAsync(() => fetch("/api"));   // Result<Response>
MemberDescription
result(value, error?)Create a Result from value and optional error
errorResult(error)Create an error Result
resultFrom(fn)Wrap a sync function — catches exceptions into error result
resultFromAsync(fn)Wrap an async function — catches rejections into error result
.hasValuetrue if the result holds a value
.hasErrortrue if the result holds an error
.valueReturns value or throws the stored error
.valueOrUndefinedReturns value or undefined (never throws)
.errorReturns the error or undefined
.equals(other, valueComparer?)Structural equality — values via valueComparer (Object.is by default), errors by reference

hasValue/hasError are independent, unforgeable flags decided when the Result is constructed (not derived from whether the error is nullish), so an error result whose error happens to be undefined/null is still an error. errorResult/resultFrom* normalize a nullish error to new Error('Unspecified error'), so throw undefined never turns into a "successful" result.

PromiseSource<T>

Externally-resolvable promise — the TypeScript equivalent of TaskCompletionSource<T>.

ts
import { PromiseSource } from "@actuallab/core";

const ps = new PromiseSource<string>();
ps.isCompleted;  // false

// Somewhere else:
ps.resolve("done");
// or: ps.reject(new Error("fail"));

const value = await ps.promise;  // "done"
ps.isCompleted;  // true
MemberDescription
.promiseThe underlying Promise<T>
.isCompletedtrue after resolve or reject
.resolve(value)Resolve the promise
.reject(error)Reject the promise

There is also a pre-resolved constant: resolvedVoidPromise.

AsyncContext

General-purpose typed context container — provides AsyncLocal<T>-like functionality for JavaScript's single-threaded environment. Named for forward-compatibility with the TC39 AsyncContext proposal.

ts
import { AsyncContext, AsyncContextKey } from "@actuallab/core";

// Define a typed key
const userKey = new AsyncContextKey<string>("user", "anonymous");

// Create context with a value
const ctx = AsyncContext.empty.with(userKey, "Alice");

// Run code within the context
ctx.run(() => {
  AsyncContext.current!.get(userKey);  // "Alice"
});

// Or activate/deactivate manually
const guard = ctx.activate();
AsyncContext.current!.get(userKey);  // "Alice"
guard.dispose();
MemberDescription
AsyncContext.currentStatic: the currently active context (or undefined)
AsyncContext.emptyStatic: immutable empty singleton
AsyncContext.from(ctx)Returns ctx if defined, otherwise .current
AsyncContext.fromArgs(args)Extract context from last argument, or fall back to .current
.get(key)Read a value by key (returns key.defaultValue if not set)
.with(key, value)Create a new context with the key set (immutable)
.run(fn)Execute fn with this context as .current
.activate()Set as .current, return a Disposable that restores the previous one
.stripFromArgs(args)Remove this context from the end of an args array

Additional statics: AsyncContext.isAsyncLocalStorageActive (true when the Node AsyncLocalStorage backing is active), AsyncContext.getOrCreate(), and AsyncContext.setDefault(key, value). The exported abortSignalKey is the well-known key the RPC/compute layers use to carry a caller's AbortSignal through the context.

WARNING

On Node ≥ 20.16 AsyncContext is backed by AsyncLocalStorage, so AsyncContext.current flows across await boundaries automatically (like C#'s AsyncLocal). In browsers it does not: after the first await the ambient context is lost, so compute methods receive the child AsyncContext as a trailing argument and must forward it into nested calls. See AsyncContext: Why It Matters.

AsyncLock

Promise-based mutual exclusion lock — the TypeScript equivalent of AsyncLock.

ts
import { AsyncLock } from "@actuallab/core";

const lock = new AsyncLock();

// Option 1: acquire/release
await lock.acquire();
try {
  // critical section
} finally {
  lock.release();
}

// Option 2: run (acquires, runs fn, releases)
await lock.run(async () => {
  // critical section
});

// Both acquire() and run() accept an optional AbortSignal — an already-aborted
// signal rejects immediately, and a queued waiter is removed and rejected with
// signal.reason when the signal fires.
await lock.acquire(abortSignal);

EventHandlerSet<T>

Typed pub/sub event system — similar to .NET's multicast delegates.

ts
import { EventHandlerSet } from "@actuallab/core";

const onChanged = new EventHandlerSet<string>();

const handler = (msg: string) => console.log(msg);
onChanged.add(handler);

onChanged.trigger("hello");  // logs "hello"
onChanged.remove(handler);

// Await the next event
const next = await onChanged.whenNext();
MemberDescription
.add(handler)Subscribe
.remove(handler)Unsubscribe
.trigger(value)Fire all handlers synchronously, over a snapshot — handlers added during dispatch don't run in that dispatch (multicast-delegate parity)
.triggerSafe(value, onError)Like trigger, but each handler is isolated so one that throws can't stop the rest
.clear()Remove all handlers
.countNumber of subscribed handlers
.whenNext()Returns a Promise that resolves on the next trigger

TIP

Because trigger iterates a snapshot, whenNext() called from inside a handler resolves on the next event, not the one being dispatched. EventHandlerSet is Set-backed, so adding the same handler twice still fires it once (a C# multicast delegate would fire it twice).

RetryDelaySeq and RetryDelayer

Retry delay sequences with exponential backoff, similar to .NET's RetryDelaySeq.

ts
import { RetryDelaySeq, RetryDelayer } from "@actuallab/core";

// Fixed 1-second delays
const fixed = RetryDelaySeq.fixed(1000);

// Exponential: 1s, 2s, 4s, ... capped at 30s
const exp = RetryDelaySeq.exp(1000, 30000);

// RetryDelayer wraps a sequence with cancellation support
const delayer = new RetryDelayer();
delayer.delays = exp;
delayer.limit = 10;  // max 10 retries
const delay = delayer.getDelay(3);  // 3rd retry
if (!delay.isLimitExceeded) {
  await delay.promise;  // wait for the delay
}

Cancellation & Abort Helpers

AbortSignal is the port's CancellationToken analog, and these helpers give it the same "cancellation is not a failure" semantics as .NET:

ts
import {
  isCancellation, cancellationError,
  delayAsync, awaitWithCleanup, retry, abortPromise,
} from "@actuallab/core";

// Recognize / construct cancellation-shaped errors (named "AbortError").
isCancellation(err);              // true for aborted fetch, AsyncLock/delay rejections, etc.
throw cancellationError();        // an Error the compute kernel never caches (see Computed)

// Cancellable delay — pre-checks signal.aborted, clears the timer on abort,
// rejects with signal.reason.
await delayAsync(1000, abortSignal);

// retry() also accepts an AbortSignal so inter-attempt delays can be cancelled.
  • isCancellation(error)true when error is cancellation-shaped (any object named 'AbortError', including an aborted fetch's DOMException).
  • cancellationError(message?) — builds such an error. Cancellation-shaped errors are never cached by the compute kernel and are treated as retryable by RPC callers.
  • delayAsync(ms, signal?) — abortable delay.
  • awaitWithCleanup(signal, abortMode, body) — the shared "await with guaranteed cleanup" primitive the delayers are built on; removes every listener/timer on every exit path.
  • abortPromise(signal) — a cached, pre-observed rejecting promise per signal (safe to hold and race later without an unhandled rejection).

PromiseSource rejections are safe when unobserved: the constructor pre-attaches a no-op .catch(), so rejecting a source nobody is awaiting will not raise unhandledrejection (the .NET "unobserved faulted task is benign" contract).

Decorator Helpers

Small utilities shared by the @computeMethod, @rpcService, and @rpcMethod decorators:

HelperDescription
ownMetadata(metadata, key)Returns the class's own metadata record for key, cloning an inherited one first — so decorating a derived class never mutates a base class's contract
resolveArgCount(fn, explicit, methodName)Resolves a method's wire argument count (explicit may be undefined); throws at declaration time when fn has a defaulted or rest parameter (ambiguous Function.length) unless an explicit count is given

DisposableBag

Aggregates multiple Disposable objects and disposes them in LIFO order.

ts
import { DisposableBag } from "@actuallab/core";

const bag = new DisposableBag();
bag.add({ dispose: () => console.log("first") });
bag.add({ dispose: () => console.log("second") });
bag.dispose();  // logs "second", then "first"