Skip to content

TypeScript Port

The primary goal of the TypeScript port is to enable TypeScript/JavaScript-based UI to consume Fusion Compute Services and ActualLab.Rpc services running on a .NET server. It gives you real-time, invalidation-driven UI updates in React (or any other JS framework) — the same way Fusion + Blazor works on .NET.

The port is intentionally more lightweight than the full .NET version. Server-side-only features like CommandR, Operations Framework, Entity Framework extensions, and Authentication are not included — these remain on the .NET server where they belong.

What the TypeScript port does provide:

  • Remote Compute Service Clients — typed proxies that call .NET Compute Services over RPC, cache results locally, and automatically invalidate when the server signals changes
  • Client-side Compute Services@computeMethod and Computed<T> work locally in the browser, so you can compose server API calls into higher-level computed values with dependency tracking
  • Reactive statesComputedState<T> and MutableState<T> for auto-updating UI
  • RPC infrastructure — WebSocket transport, streaming (RpcStream<T>), fire-and-forget (RpcNoWait), automatic reconnection
  • React integrationuseComputedState hook for real-time rendering

In short, it is exactly what you need to use Fusion services from a JavaScript/TypeScript client.

TIP

This section assumes you are familiar with Fusion's core concepts (Compute Services, Computed<T>, States, ActualLab.Rpc). It focuses on the TypeScript API surface and key differences from the .NET version.

npm Packages

PackageDescription.NET Counterpart
@actuallab/coreCore primitives: Result, AsyncContext, AsyncLock, PromiseSource, eventsActualLab.Core
@actuallab/fusionComputed<T>, @computeMethod, ComputedState, MutableState, UIActionTrackerActualLab.Fusion
@actuallab/rpcRpcHub, RpcClientPeer, RpcStream, decorators, WebSocket transportActualLab.Rpc
@actuallab/fusion-rpcFusionHub — compute-aware RPC with automatic invalidation propagationActualLab.Fusion (client part)
@actuallab/fusion-reactReact hooks: useComputedState, useMutableStateActualLab.Fusion.Blazor

All packages are ESM-first (with CJS fallback), MIT-licensed, built with tsup, and tested with vitest.

Architecture Overview

The typical setup mirrors Fusion + Blazor, but with React on the client:

┌─────────────────────────────┐     WebSocket      ┌────────────────────────────┐
│  TypeScript Client (React)  │ <=================> │  .NET Server (Fusion)      │
│                             │   ActualLab.Rpc     │                            │
│  FusionHub + RpcClientPeer  │                     │  Compute Services          │
│  Compute Service Clients    │  ← invalidation ──  │  Computed<T> cache         │
│  @computeMethod local cache │                     │  ActualLab.Rpc server      │
│  useComputedState (React)   │                     │                            │
└─────────────────────────────┘                     └────────────────────────────┘
  1. FusionHub manages the RPC connection and creates typed client proxies
  2. Client proxies route calls through ComputeFunction for local caching + dependency tracking
  3. When the server invalidates a Computed<T>, it sends $sys-c.Invalidate to the client
  4. The client invalidates its local replica, which cascades to any dependent ComputedState
  5. React re-renders via useComputedState

Quick Start

ts
import { FusionHub } from "@actuallab/fusion-rpc";
import { RpcClientPeer, RpcPeerStateMonitor } from "@actuallab/rpc";
import { defineComputeService } from "@actuallab/fusion-rpc";

// 1. Define your service (must match the .NET interface)
const TodoApiDef = defineComputeService("ITodoApi", {
  Get:        { args: ["", ""] },          // (session, id) → TodoItem
  ListIds:    { args: ["", 0] },           // (session, count) → string[]
  GetSummary: { args: [""] },              // (session) → TodoSummary
  AddOrUpdate: { args: [{}], callTypeId: 0 },  // command (non-compute)
  Remove:      { args: [{}], callTypeId: 0 },  // command (non-compute)
});

// 2. Create hub + peer
const hub = new FusionHub();
const peer = new RpcClientPeer(hub, "ws://localhost:5005/rpc/ws");
hub.addPeer(peer);

// 3. Create typed client proxy
const api = hub.addClient<ITodoApi>(peer, TodoApiDef);

// 4. Connection + reconnect loop starts automatically from the RpcClientPeer ctor
//    (pass `false` as the 3rd arg if you need to tweak options before starting).

// 5. Use the client — compute method results are cached + invalidated automatically
const items = await api.ListIds("~", 10);

Key Differences from .NET

Aspect.NETTypeScript
Dependency Injectionservices.AddFusion() + DI containerExplicit construction: new FusionHub(), hub.addClient(...)
Compute method marker[ComputeMethod] attribute + virtual method@computeMethod decorator
InvalidationInvalidation.Begin() blockboundMethod.invalidate(...args)
Service interfaceC# interface + proxy generationdefineComputeService() or @rpcService / @rpcMethod decorators
CancellationCancellationTokenAbortSignal (via AsyncContext)
Async contextExecutionContext / AsyncLocal<T>AsyncContext — backed by AsyncLocalStorage on Node ≥ 20.16 (flows across await), explicit threading in browsers
SerializationMemoryPack / MessagePack / System.Text.JsonJSON (json5np) or MessagePack (msgpack6/msgpack6c); no MemoryPack, no polymorphism
UI integrationComputedStateComponent<T> (Blazor)useComputedState hook (React)
State factoryIServiceProvider.StateFactory()new ComputedState(computer, options) / new MutableState(initial)
StreamingRpcStream<T> + IAsyncEnumerable<T>RpcStream<T> + AsyncIterable<T> (for await...of)
Fire-and-forgetTask<RpcNoWait> return type{ returns: RpcType.noWait } in service definition

AsyncContext: Why It Matters

JavaScript is single-threaded but runs asynchronous operations via the event loop. Fusion's @computeMethod machinery needs to know which Computed<T> is currently being computed so it can record dependencies; it tracks this through AsyncContext.current.

Unlike .NET's AsyncLocal<T>, a plain static field does not survive await boundaries. The port closes this gap in two ways, depending on where it runs:

  • Node ≥ 20.16 (server, SSR, tests)AsyncContext is automatically backed by AsyncLocalStorage (loaded via process.getBuiltinModule('node:async_hooks')), so AsyncContext.current flows across every await exactly like C#'s AsyncLocal. Dependency capture "just works" — you don't need to do anything. AsyncContext.isAsyncLocalStorageActive is true here.
  • Browsers — there is no AsyncLocalStorage, so after the first await the ambient AsyncContext.current is gone. To keep dependency tracking working, ComputeFunction threads the child AsyncContext into your compute method as a trailing argument. Accept it and pass it into nested compute calls (or computed.use(ctx)) made after an await:
ts
class Todos {
  @computeMethod
  async list(count: number, ctx?: AsyncContext): Promise<TodoItem[]> {
    ctx ??= AsyncContext.current;
    const ids = await this.api.ListIds("~", count, ctx);
    // Passing ctx keeps dependency tracking alive across the awaits, even in a browser.
    const items: TodoItem[] = [];
    for (const id of ids) {
      const item = await this.api.Get("~", id, ctx);
      if (item) items.push(item);
    }
    return items;
  }
}

TIP

On Node the trailing ctx argument is harmless (the ambient context already flows), so writing compute methods to accept and forward it is the portable pattern that works everywhere. If you only call compute methods from React hooks or non-compute code, you don't need to worry about AsyncContext at all.

Sample App

The TodoApp TypeScript UI demonstrates a complete React + Fusion setup including:

  • Compute service client definition with defineComputeService
  • Client-side @computeMethod that composes server calls
  • useComputedState for real-time React rendering
  • UIActionTracker for optimistic UI updates
  • RpcPeerStateMonitor for connection status UI
  • Automatic reconnection with exponential backoff