Appearance
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 —
@computeMethodandComputed<T>work locally in the browser, so you can compose server API calls into higher-level computed values with dependency tracking - Reactive states —
ComputedState<T>andMutableState<T>for auto-updating UI - RPC infrastructure — WebSocket transport, streaming (
RpcStream<T>), fire-and-forget (RpcNoWait), automatic reconnection - React integration —
useComputedStatehook 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
| Package | Description | .NET Counterpart |
|---|---|---|
@actuallab/core | Core primitives: Result, AsyncContext, AsyncLock, PromiseSource, events | ActualLab.Core |
@actuallab/fusion | Computed<T>, @computeMethod, ComputedState, MutableState, UIActionTracker | ActualLab.Fusion |
@actuallab/rpc | RpcHub, RpcClientPeer, RpcStream, decorators, WebSocket transport | ActualLab.Rpc |
@actuallab/fusion-rpc | FusionHub — compute-aware RPC with automatic invalidation propagation | ActualLab.Fusion (client part) |
@actuallab/fusion-react | React hooks: useComputedState, useMutableState | ActualLab.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) │ │ │
└─────────────────────────────┘ └────────────────────────────┘FusionHubmanages the RPC connection and creates typed client proxies- Client proxies route calls through
ComputeFunctionfor local caching + dependency tracking - When the server invalidates a
Computed<T>, it sends$sys-c.Invalidateto the client - The client invalidates its local replica, which cascades to any dependent
ComputedState - 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 | .NET | TypeScript |
|---|---|---|
| Dependency Injection | services.AddFusion() + DI container | Explicit construction: new FusionHub(), hub.addClient(...) |
| Compute method marker | [ComputeMethod] attribute + virtual method | @computeMethod decorator |
| Invalidation | Invalidation.Begin() block | boundMethod.invalidate(...args) |
| Service interface | C# interface + proxy generation | defineComputeService() or @rpcService / @rpcMethod decorators |
| Cancellation | CancellationToken | AbortSignal (via AsyncContext) |
| Async context | ExecutionContext / AsyncLocal<T> | AsyncContext — backed by AsyncLocalStorage on Node ≥ 20.16 (flows across await), explicit threading in browsers |
| Serialization | MemoryPack / MessagePack / System.Text.Json | JSON (json5np) or MessagePack (msgpack6/msgpack6c); no MemoryPack, no polymorphism |
| UI integration | ComputedStateComponent<T> (Blazor) | useComputedState hook (React) |
| State factory | IServiceProvider.StateFactory() | new ComputedState(computer, options) / new MutableState(initial) |
| Streaming | RpcStream<T> + IAsyncEnumerable<T> | RpcStream<T> + AsyncIterable<T> (for await...of) |
| Fire-and-forget | Task<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) —
AsyncContextis automatically backed byAsyncLocalStorage(loaded viaprocess.getBuiltinModule('node:async_hooks')), soAsyncContext.currentflows across everyawaitexactly like C#'sAsyncLocal. Dependency capture "just works" — you don't need to do anything.AsyncContext.isAsyncLocalStorageActiveistruehere. - Browsers — there is no
AsyncLocalStorage, so after the firstawaitthe ambientAsyncContext.currentis gone. To keep dependency tracking working,ComputeFunctionthreads the childAsyncContextinto your compute method as a trailing argument. Accept it and pass it into nested compute calls (orcomputed.use(ctx)) made after anawait:
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
@computeMethodthat composes server calls useComputedStatefor real-time React renderingUIActionTrackerfor optimistic UI updatesRpcPeerStateMonitorfor connection status UI- Automatic reconnection with exponential backoff
