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. Start the connection (reconnects automatically)
void peer.run();
// 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 (explicit, thread-local-like) |
| Serialization | MemoryPack / MessagePack / System.Text.Json | JSON (json5np format, 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. Unlike .NET's ExecutionContext that automatically flows through await boundaries, JavaScript has no built-in async context propagation (the TC39 AsyncContext proposal is still Stage 2).
Fusion's @computeMethod decorator needs to track which Computed<T> is currently being computed, so it uses AsyncContext.current — a thread-local-like static field. This works perfectly for synchronous code, but across await boundaries the context can be lost if another microtask runs in between.
In practice, you may need to pass AsyncContext explicitly as the last argument when calling compute methods from within other compute methods that cross await points:
ts
class Todos {
@computeMethod
async list(count: number, ctx?: AsyncContext): Promise<TodoItem[]> {
ctx ??= AsyncContext.current;
const ids = await this.api.ListIds("~", count, ctx);
// ctx ensures dependency tracking survives the await above
const items: TodoItem[] = [];
for (const id of ids) {
const item = await this.api.Get("~", id, ctx);
if (item) items.push(item);
}
return { items };
}
}TIP
AsyncContext is only needed when you call compute methods across await boundaries inside other compute methods. If you only call compute methods from React hooks or non-compute code, you don't need to worry about it.
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
