@hotreloads/crux
v1.0.1
Published
Framework-agnostic TypeScript system — separate business logic from UI
Maintainers
Readme
@hotreloads/crux
A framework-agnostic TypeScript actor system library for separating business logic from UI components.
Actors are isolated units of business logic. They communicate through typed ask/reply (request-response) and pub/sub (reactive state). Your UI components become thin wrappers that delegate all decisions to actors — making your business logic testable, reusable across frameworks, and easy to reason about.
Why crux?
The problem: Business logic living inside components creates tight coupling — logic gets duplicated across pages, becomes hard to test, and re-implementing it for a new framework means starting over.
The solution: Actors live outside the component tree. A React component, a Svelte component, and a plain fetch handler can all talk to the same AuthActor without changing a line of actor code.
┌─────────────────────────────┐
React Component ──► │ │
Svelte Component ──► │ ActorSystem (registry) │ ──► AuthActor
API Route handler ──► │ │ ──► CartActor
└─────────────────────────────┘ ──► NotificationActorInstallation
npm install @hotreloads/cruxTypeScript 5.4+ is required. No other runtime dependencies.
Table of Contents
- CLI — Scaffold & Manage
- Core Concepts
- Quick Start
- Feature 1 — Type-Safe Asks
- Feature 2 — React / Next.js Hooks
- Feature 3 — Svelte Adapter
- Feature 4 — DevTools Panel
- Feature 5 — Testing Utilities
- Feature 6 — Persistence
- Feature 7 — Middleware / Interceptors
- Feature 8 — Remote Actors (Cross-tab Bridge)
- Feature 9 — Schema Validation
- Feature 10 — Request Cancellation
- Feature 11 — Actor Supervision
- Package Exports
- API Reference
CLI — Scaffold & Manage
The fastest way to get started is with @hotreloads/crux-cli. It detects your framework, generates all the setup files, and auto-wires new actors into your project.
Initialize
npx @hotreloads/crux-cli initThe CLI detects whether you're using React + Vite, Next.js, or SvelteKit and generates the appropriate setup:
? Detected React + Vite project. Is this correct? Yes
? Where should actor files live? src/actors
? Enable devtools in development? Yes
✔ crux initialized for React + Vite
Files created:
src/actors/actor-address.ts
src/actors/register-actors.ts
src/actors/context.tsx
src/actors/index.tsFor React/Next.js, it generates an ActorProvider and a useActorRef hook. For SvelteKit, it generates store-based helpers using actorSubscribe and actorAsk.
After init, wrap your app with the provider:
// main.tsx (React + Vite)
import { ActorProvider } from "./actors";
ReactDOM.createRoot(document.getElementById("root")!).render(
<ActorProvider>
<App />
</ActorProvider>
);Create Actors
npx @hotreloads/crux-cli add actor authThis generates a typed actor with an example ask, and automatically wires it into your address registry, actor system registration, and barrel exports:
✔ Actor "AuthActor" created
Files created:
src/actors/auth/auth-actor.ts
src/actors/auth/auth-actor-types.ts
Wired into:
src/actors/actor-address.ts
src/actors/register-actors.tsThe generated actor includes a type-safe AskMap, static ASKS and TOPICS constants, and a working example you can modify:
// src/actors/auth/auth-actor-types.ts
import type { ActorAskMap } from "@hotreloads/crux";
export type AuthActorAsks = ActorAskMap & {
getStatus: {
payload: { id: string };
result: { status: string };
};
};// src/actors/auth/auth-actor.ts
export class AuthActor extends Actor {
static ASKS = {
GET_STATUS: "getStatus",
} as const;
static TOPICS = {
STATUS: "auth_status",
} as const;
async ask(asktype: string, data: unknown): Promise<ActorResult> {
switch (asktype) {
case AuthActor.ASKS.GET_STATUS: {
const payload = data as AuthActorAsks["getStatus"]["payload"];
// TODO: implement your logic here
this.publish(AuthActor.TOPICS.STATUS, { status: "active" }, true);
return [{ status: "active" }, null];
}
default:
return [null, this.errunsupportedask(asktype, data)];
}
}
}More CLI Commands
# Add middleware (logging, auth guard, retry, or custom)
npx @hotreloads/crux-cli add middleware
# Set up cross-tab communication
npx @hotreloads/crux-cli add bridge
# Add persistence or validation to an existing actor
npx @hotreloads/crux-cli enhance payments --add-persistence
npx @hotreloads/crux-cli enhance payments --add-validation
# See all registered actors
npx @hotreloads/crux-cli listFull CLI documentation: @hotreloads/crux-cli
Core Concepts
Actor
An actor is a class that extends Actor. It holds state, handles requests via ask(), and broadcasts state changes via publish().
import { Actor } from "@hotreloads/crux";
import type { ActorResult } from "@hotreloads/crux";
class CounterActor extends Actor {
#count = 0;
async ask(asktype: string, data: unknown): Promise<ActorResult> {
switch (asktype) {
case "increment":
this.#count++;
this.publish("count", this.#count, true); // true = retain for late subscribers
return [this.#count, null];
case "reset":
this.#count = 0;
this.publish("count", this.#count, true);
return [true, null];
default:
return [null, this.errunsupportedask(asktype, data)];
}
}
}ActorSystem
The registry. Actors are registered by a (widgetid, address) pair.
import { ActorSystem } from "@hotreloads/crux";
const system = new ActorSystem();
system.registerChild("app", "counter", new CounterActor());ActorRef
A proxy handle to an actor. You get one from the system and use it to send asks and subscribe to topics.
const ref = system.actorOfChild("app", "counter");
// Ask/reply
const [count, err] = await ref.ask("increment");
// Pub/sub — fires immediately with the retained value if available
ref.subscribe("count", (topic, data) => {
console.log("count is now", data);
});ActorResult
Every ask returns Promise<[data, null] | [null, ActorError]>. Destructure and check the error slot:
const [user, err] = await authRef.ask("signin", { email, password });
if (err) {
showError(err.errmsg);
return;
}
// user is the signed-in user objectQuick Start
// 1. Define your actor
class TodoActor extends Actor {
#todos: string[] = [];
async ask(asktype: string, data: unknown): Promise<ActorResult> {
switch (asktype) {
case "add":
this.#todos.push(data as string);
this.publish("todos", [...this.#todos], true);
return [true, null];
case "getAll":
return [this.#todos, null];
default:
return [null, this.errunsupportedask(asktype, data)];
}
}
}
// 2. Register it
const system = new ActorSystem();
system.registerChild("app", "todo", new TodoActor());
// 3. Get a ref and use it
const todoRef = system.actorOfChild("app", "todo");
await todoRef.ask("add", "Buy groceries");
await todoRef.ask("add", "Walk the dog");
todoRef.subscribe("todos", (_, todos) => {
console.log(todos); // ['Buy groceries', 'Walk the dog']
});Feature 1 — Type-Safe Asks
What it does: Enforces correct ask types, payload shapes, and result types at compile time. TypeScript catches typos in ask names and wrong payload shapes before the code runs.
When to use it: Always. Define an AskMap type for every actor you build. The overhead is zero at runtime — it's pure TypeScript.
Real-world example: Authentication actor
Without type safety, you rely on documentation and discipline:
// ❌ No type safety — typos compile silently, wrong payloads aren't caught
await authRef.ask("sigin", { email, pasword }); // typo in asktype, typo in fieldWith type-safe asks, the compiler is your first line of defence:
// auth-actor-types.ts
import type { ActorAskMap } from "@hotreloads/crux";
export type AuthActorAsks = ActorAskMap & {
signin: {
payload: { email: string; password: string };
result: { accesstoken: string; userhandle: string };
};
logout: { payload: never; result: boolean };
getProfile: {
payload: never;
result: { name: string; email: string } | null;
};
refresh: {
payload: { refreshtoken: string };
result: { accesstoken: string };
};
};// auth-actor.ts
import { Actor } from "@hotreloads/crux";
import type { ActorResult } from "@hotreloads/crux";
import type { AuthActorAsks } from "./auth-actor-types";
export class AuthActor extends Actor {
#accesstoken: string | null = null;
async ask(asktype: string, data: unknown): Promise<ActorResult> {
switch (asktype) {
case "signin": {
const { email, password } = data as AuthActorAsks["signin"]["payload"];
const result = await api.signin(email, password);
if (!result.ok)
return [
null,
{
errcode: "INVALID_CREDENTIALS",
errmsg: "Wrong email or password",
errdata: null,
},
];
this.#accesstoken = result.accesstoken;
this.publish(
"auth_state",
{ signedIn: true, userhandle: result.userhandle },
true,
);
return [
{ accesstoken: result.accesstoken, userhandle: result.userhandle },
null,
];
}
case "logout": {
this.#accesstoken = null;
this.publish("auth_state", { signedIn: false, userhandle: null }, true);
return [true, null];
}
// ...
}
}
}// anywhere in your app
import type { AuthActorAsks } from "./auth-actor-types";
const authRef = system.actorOfChild<AuthActorAsks>("app", "auth");
// ✅ TypeScript knows payload shape and result type
const [session, err] = await authRef.ask("signin", { email, password });
// ✅ Compile error: 'sigin' is not a key of AuthActorAsks
await authRef.ask("sigin", { email, password });
// ✅ Compile error: 'pasword' does not exist on the payload type
await authRef.ask("signin", { email, pasword: "..." });Feature 2 — React / Next.js Hooks
What it does: Two hooks — useActorSubscribe for reactive state, useActorAsk for form submissions and mutations. Both work with any ref type (local or remote).
When to use it: Any React or Next.js component that needs to display actor state or trigger actor actions.
Install: No extra install. Import from the @hotreloads/crux/react subpath.
import { useActorSubscribe, useActorAsk } from "@hotreloads/crux/react";Real-world example: User profile page
A profile page that shows live data and lets the user update their display name:
// components/ProfilePage.tsx
"use client"; // Next.js App Router
import { useActorSubscribe, useActorAsk } from "@hotreloads/crux/react";
import { useActorRefs } from "../actors"; // your app's ref provider
import type { ProfileActorAsks } from "../actors/profile-actor-types";
type Profile = { name: string; email: string; avatarUrl: string };
export function ProfilePage() {
const { profileRef } = useActorRefs();
// Subscribes once on mount, unsubscribes on unmount.
// Delivers the retained value immediately if the actor has published one.
const profile = useActorSubscribe<Profile>(profileRef, "profile_data");
// Returns a stable `call` function — safe to use in event handlers.
const {
call: updateName,
loading,
error,
} = useActorAsk<ProfileActorAsks, "updateName">(profileRef, "updateName");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const name = new FormData(e.currentTarget).get("name") as string;
const [_, err] = await updateName({ name });
if (err) toast.error(err.errmsg);
};
if (!profile) return <Skeleton />;
return (
<div>
<img src={profile.avatarUrl} alt={profile.name} />
<h1>{profile.name}</h1>
<p>{profile.email}</p>
<form onSubmit={handleSubmit}>
<input name="name" defaultValue={profile.name} />
<button type="submit" disabled={loading}>
{loading ? "Saving..." : "Update name"}
</button>
{error && <p className="error">{error.errmsg}</p>}
</form>
</div>
);
}// actors/profile-actor-types.ts
export type ProfileActorAsks = ActorAskMap & {
updateName: { payload: { name: string }; result: Profile };
uploadAvatar: { payload: { file: File }; result: { avatarUrl: string } };
};Feature 3 — Svelte Adapter
What it does: Two helpers that bridge actor refs to native Svelte stores — actorSubscribe wraps a retained topic in a Readable store, and actorAsk returns a stable call function alongside loading, data, and error stores. Both clean up subscriptions automatically when all store subscribers detach.
When to use it: Any Svelte or SvelteKit component that needs to display actor state or trigger actor actions.
Install: No extra install. Import from the @hotreloads/crux/svelte subpath.
import { actorSubscribe, actorAsk } from "@hotreloads/crux/svelte";Real-world example: Auth status and sign-in form
<!-- components/Auth.svelte -->
<script lang="ts">
import { actorSubscribe, actorAsk } from '@hotreloads/crux/svelte';
import { authRef } from '../actors';
import type { AuthActorAsks } from '../actors/auth-actor-types';
// Reactive store — updates whenever the actor publishes to 'auth_state'
const authState = actorSubscribe<{ signedIn: boolean; userhandle: string | null }>(authRef, 'auth_state');
// Returns a stable call() function + loading/data/error stores
const { call: signin, loading, error } = actorAsk<AuthActorAsks, 'signin'>(authRef, 'signin');
let email = '';
let password = '';
async function handleSubmit() {
await signin({ email, password });
}
</script>
{#if $authState?.signedIn}
<p>Welcome, {$authState.userhandle}!</p>
{:else}
<form on:submit|preventDefault={handleSubmit}>
<input bind:value={email} type="email" placeholder="Email" />
<input bind:value={password} type="password" placeholder="Password" />
<button type="submit" disabled={$loading}>
{$loading ? 'Signing in...' : 'Sign in'}
</button>
{#if $error}<p class="error">{$error.errmsg}</p>{/if}
</form>
{/if}Peer dependency: svelte >= 4 (only required for this subpath).
Feature 4 — DevTools Panel
What it does: A floating, resizable in-app DevTools panel for the actor system. Renders nothing in production builds.
When to use it: During development whenever you're debugging unexpected state, tracing slow asks, or onboarding a new team member who needs to understand what actors are doing.
Install: Import from @hotreloads/crux/devtools and @hotreloads/crux/devtools/react.
Panel tabs
| Tab | What you see |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Actors | All registered actors with live health badges (green/yellow/red based on error rate). Click an actor to see its retained topic values and structural diffs from the previous publish. |
| Log | Rolling event log (last 200 events) of every ask, askreply, publish, register, and unregister. Ask/reply pairs are correlated by ID and linked. Slow asks (≥ 500 ms by default) are highlighted. Click any row to expand payload, result, and call stack. Search/filter by actor ID or event type. |
| Stats | Per-actor, per-ask-type table with call count, error count, success rate %, avg/p95/max latency (ms). Sortable. |
| Timeline | Visual timeline of all log events scaled to real time. Each event is a coloured bar; hover for details. Helps spot patterns like bursts or gaps between ask and reply. |
| Graph | SVG dependency graph showing actor nodes (with health dots) and directed edges for actor→actor calls (including external/component calls). Click a node to highlight its connections. Edge labels show call counts. |
DevTools configuration
const devtools = new ActorDevTools();
// Flag asks that take longer than 300 ms as slow (default: 500)
devtools.slowAskThresholdMs = 300;
// Capture Error().stack at each ask call site for the Log tab (default: false — has overhead)
devtools.captureCallSites = true;
// Keep more log entries (default: 200)
devtools.maxLogSize = 500;Setup example
// actors/index.ts
import { ActorSystem } from "@hotreloads/crux";
import { ActorDevTools } from "@hotreloads/crux/devtools";
export const actorSystem = new ActorSystem();
if (process.env.NODE_ENV !== "production") {
actorSystem.devtools = new ActorDevTools();
}
actorSystem.registerChild("app", "cart", new CartActor(actorSystem));
actorSystem.registerChild("app", "pricing", new PricingActor(actorSystem));
actorSystem.registerChild("app", "promo", new PromoActor(actorSystem));// app/layout.tsx (Next.js App Router)
"use client";
import { ActorDevToolsPanel } from "@hotreloads/crux/devtools/react";
import { actorSystem } from "../actors";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Renders nothing in production — safe to ship */}
<ActorDevToolsPanel devtools={actorSystem.devtools} />
</body>
</html>
);
}The panel appears anchored to the bottom-right corner. Drag the grip handle in the top-left corner of the panel to resize it. The toggle button stays visible when the panel is closed.
Debugging workflow example
You're building a checkout flow and the cart total is wrong. Open the Actors tab → click app/cart → see the retained cart_state value and its diff from the previous publish. Then switch to the Log tab → filter by actor pricing → expand the calculate reply and see the wrong base price in the result payload. Root cause found in under a minute.
Feature 5 — Testing Utilities
What it does: Two test helpers — ActorTestHarness registers a real actor in a minimal system so you can test its full ask/reply and pub/sub behaviour, and MockActorRef lets you inject a fake ref into actors that depend on other actors.
When to use it: Unit testing any actor. Use ActorTestHarness to test the actor under test; use MockActorRef to stub out its dependencies.
Install: Import from @hotreloads/crux/testing.
Real-world example: Testing a notification actor that depends on auth
NotificationActor needs to know the current user (from AuthActor) before it can fetch notifications. In tests, you don't want a real auth server — mock it.
// tests/notification-actor.test.ts
import { ActorTestHarness, MockActorRef } from "@hotreloads/crux/testing";
import { NotificationActor } from "../actors/notification-actor";
import type { AuthActorAsks } from "../actors/auth-actor-types";
import type { NotificationActorAsks } from "../actors/notification-actor-types";
describe("NotificationActor", () => {
let harness: ActorTestHarness<NotificationActorAsks>;
let mockAuthRef: MockActorRef<AuthActorAsks>;
beforeEach(() => {
mockAuthRef = new MockActorRef<AuthActorAsks>();
// Simulate a signed-in user retained on the auth topic
mockAuthRef.emit(
"auth_state",
{ signedIn: true, userhandle: "alice" },
true,
);
harness = new ActorTestHarness(new NotificationActor(mockAuthRef));
});
afterEach(() => harness.destroy());
it("fetches notifications for the signed-in user", async () => {
const notifications = harness.collect("notifications");
const [result, err] = await harness.ask("fetchAll");
expect(err).toBeNull();
expect(result).toHaveLength(3);
// The actor should publish to the 'notifications' topic on success
expect(notifications).toHaveLength(1);
expect(notifications[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(String) }),
]),
);
});
it("returns error when user is not signed in", async () => {
// Override: signed-out state
mockAuthRef.emit("auth_state", { signedIn: false, userhandle: null }, true);
const [result, err] = await harness.ask("fetchAll");
expect(result).toBeNull();
expect(err?.errcode).toBe("NOT_AUTHENTICATED");
});
it("waits for the actor to publish after an async operation", async () => {
const nextPublish = harness.waitForPublish("notifications");
await harness.ask("markAllRead");
const updated = await nextPublish;
expect((updated as Notification[]).every((n) => n.read)).toBe(true);
});
it("records all mock calls for verification", async () => {
// Verify the actor asked the right thing of its dependency
await harness.ask("fetchAll");
expect(mockAuthRef.wasCalled("getProfile")).toBe(true);
});
});Feature 6 — Persistence
What it does: PersistentActor extends Actor with automatic read/write to localStorage, sessionStorage, or any custom storage adapter. Retained topic values survive page refreshes and are hydrated synchronously on startup.
When to use it: Any actor that holds state the user would expect to survive a refresh — shopping cart, auth session, user preferences, form drafts.
Real-world example: Shopping cart that survives page refresh
Without persistence, a user adds items to their cart, accidentally refreshes the page, and loses everything. With PersistentActor:
// actors/cart-actor.ts
import { PersistentActor } from "@hotreloads/crux";
import type { ActorResult } from "@hotreloads/crux";
type CartItem = { id: string; name: string; qty: number; price: number };
type CartState = { items: CartItem[]; total: number };
export class CartActor extends PersistentActor {
#items: CartItem[] = [];
constructor(actorSysI: unknown) {
super(actorSysI);
// SSR-safe: pass null on the server, localStorage in the browser
this.setupPersistence({
storage: typeof window !== "undefined" ? localStorage : null,
key: "cart",
topics: ["cart_state"], // only retained publishes to this topic are persisted
});
// After setupPersistence(), the pubstore is already hydrated.
// If the user had items before, they're back — no flash of empty cart.
const persisted = this.getPersistedValue("cart_state") as
| CartState
| undefined;
if (persisted) {
this.#items = persisted.items;
}
}
async ask(asktype: string, data: unknown): Promise<ActorResult> {
switch (asktype) {
case "addItem": {
const item = data as CartItem;
const existing = this.#items.find((i) => i.id === item.id);
if (existing) {
existing.qty += item.qty;
} else {
this.#items.push(item);
}
this.#publishCart();
return [true, null];
}
case "removeItem": {
const { id } = data as { id: string };
this.#items = this.#items.filter((i) => i.id !== id);
this.#publishCart();
return [true, null];
}
case "clear": {
this.#items = [];
this.clearPersistence(); // wipe localStorage on checkout or logout
this.#publishCart();
return [true, null];
}
}
return [null, this.errunsupportedask(asktype, data)];
}
#publishCart(): void {
const total = this.#items.reduce((sum, i) => sum + i.price * i.qty, 0);
// retain: true → written to localStorage automatically
this.publish("cart_state", { items: [...this.#items], total }, true);
}
}// Custom storage adapter example (e.g. for React Native / AsyncStorage wrapper)
const secureStorage: StorageAdapter = {
getItem: (key) => SecureStore.getItemSync(key),
setItem: (key, value) => SecureStore.setItemSync(key, value),
removeItem: (key) => SecureStore.deleteItemSync(key),
};Feature 7 — Middleware / Interceptors
What it does: actorSystem.use(middleware) registers a global ask interceptor. Every ask to every actor in the system passes through the middleware chain. Middleware can log, short-circuit, modify payloads, retry, or add cross-cutting concerns without touching actor code.
When to use it: Logging, analytics, auth guards, request deduplication, retry logic, rate limiting. Any concern that applies across multiple actors.
Real-world example: Auth token injection + request logging + retry
Your app has 10 actors that all talk to the same API. Every ask needs the current auth token attached. Rather than duplicating token logic in every actor:
import type { AskMiddleware } from "@hotreloads/crux";
// Middleware 1: request/response logging
const logger: AskMiddleware = (ctx, next) => async (asktype, payload) => {
const t0 = Date.now();
console.log(`[ask] ${ctx.actorid}/${asktype}`, payload);
const result = await next(asktype, payload);
const [, err] = result;
console.log(
`[reply] ${ctx.actorid}/${asktype} ${Date.now() - t0}ms`,
err ? `ERR:${err.errcode}` : "OK",
);
return result;
};
// Middleware 2: inject auth token into every ask payload
const authInjector: AskMiddleware = (_ctx, next) => (asktype, payload) => {
const token = tokenStore.getAccessToken();
if (!token) {
return Promise.resolve([
null,
{
errcode: "UNAUTHENTICATED",
errmsg: "No auth token — please sign in",
errdata: null,
},
]);
}
// Merge the token into the payload object
const enriched =
payload && typeof payload === "object"
? { ...(payload as object), _token: token }
: payload;
return next(asktype, enriched);
};
// Middleware 3: retry once on network errors
const retryOnNetwork: AskMiddleware =
(_ctx, next) => async (asktype, payload) => {
const [data, err] = await next(asktype, payload);
if (err?.errcode === "NETWORK_ERROR") {
console.warn(`Network error on ${asktype}, retrying once...`);
await new Promise((r) => setTimeout(r, 1_000));
return next(asktype, payload);
}
return [data, err];
};
// Register in order: logger runs first (outermost), retry last (innermost wrapping the actual ask)
actorSystem.use(logger).use(authInjector).use(retryOnNetwork);
// All actors now have logging + auth injection + retry — zero changes to actor code
const [order, err] = await orderRef.ask("placeOrder", { cartId: "123" });Feature 8 — Remote Actors (Cross-tab Bridge)
What it does: Makes actors in one browser tab (or context) accessible from another, using the Web BroadcastChannel API. The remote actor looks identical to a local one — same ask() / subscribe() interface.
When to use it: When users open your app in multiple tabs and you need state to be consistent. A notification badge in Tab B should update when Tab A receives a push message. A cart change in Tab A should appear in Tab B.
Install: Import from @hotreloads/crux/bridge and @hotreloads/crux/bridge/broadcastchannel.
Real-world example: Live notification badge across tabs
The user has your app open in two tabs. Tab A is the active one receiving push messages via WebSocket. Tab B is in the background but should still show the unread notification count in the favicon/title.
// Tab A — has the NotificationActor (the "host" tab)
import { ActorBridgeHost } from "@hotreloads/crux/bridge";
import { BroadcastChannelTransport } from "@hotreloads/crux/bridge/broadcastchannel";
const system = new ActorSystem();
system.registerChild("app", "notifications", new NotificationActor());
// Expose the actor system to other tabs
const host = new ActorBridgeHost(
system,
new BroadcastChannelTransport("my-app"),
);
// Clean up when the tab closes
window.addEventListener("unload", () => host.close());// Tab B — wants to display the notification count (the "client" tab)
import { ActorBridgeClient } from "@hotreloads/crux/bridge";
import { BroadcastChannelTransport } from "@hotreloads/crux/bridge/broadcastchannel";
import type { NotificationActorAsks } from "../actors/notification-actor-types";
const client = new ActorBridgeClient(new BroadcastChannelTransport("my-app"));
// This ref behaves exactly like a local ActorRef
const notifRef = client.remoteRef<NotificationActorAsks>("app/notifications");
// Subscribe to real-time count updates from Tab A
notifRef.subscribe("unread_count", (_, count) => {
document.title = count > 0 ? `(${count}) My App` : "My App";
updateFavicon(count as number);
});
// Can also make asks across tabs
const [notifications, err] = await notifRef.ask("getAll");// Works with React hooks too — remote ref satisfies IActorRef
const unreadCount = useActorSubscribe<number>(notifRef, "unread_count");
return <NotificationBell count={unreadCount ?? 0} />;Default ask timeout is 5 seconds. If the host tab is gone, the ask resolves with { errcode: 'REMOTE_TIMEOUT' } rather than hanging forever. Customise per ref:
const ref = client.remoteRef<NotificationActorAsks>(
"app/notifications",
10_000,
); // 10s timeoutFeature 9 — Schema Validation
What it does: Two complementary approaches — createValidationMiddleware for boundary validation at the system level, and ValidatedActor for self-contained validation co-located with handlers. Both use a Zod-compatible interface (or any validator with .safeParse()).
When to use it: Any actor that accepts user-supplied input. Validation catches malformed requests early with clear error messages, prevents corrupt state from reaching your business logic, and serves as executable documentation.
Install: Import from @hotreloads/crux/validation. Install Zod separately (npm i zod).
Real-world example: User registration with business rules
A registration actor that enforces email format, password strength, username uniqueness, and age requirements — all with precise error messages:
// actors/registration-actor.ts
import { z } from "zod";
import { ValidatedActor } from "@hotreloads/crux/validation";
const RegisterSchema = z.object({
email: z.string().email("Must be a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
.regex(/[0-9]/, "Must contain at least one number"),
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be 20 characters or fewer")
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),
dateOfBirth: z.string().date("Must be a valid date (YYYY-MM-DD)"),
acceptedTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms" }),
}),
});
const UpdateEmailSchema = z.object({
newEmail: z.string().email(),
password: z.string().min(1, "Current password is required to change email"),
});
type RegisterPayload = z.infer<typeof RegisterSchema>;
type User = { id: string; email: string; username: string };
export class RegistrationActor extends ValidatedActor {
constructor(actorSysI: unknown) {
super(actorSysI);
this.defineAsk(
"register",
RegisterSchema,
async (payload: RegisterPayload) => {
// payload is fully typed and validated — no need to re-check
const age = getAge(payload.dateOfBirth);
if (age < 13) {
return [
null,
{
errcode: "TOO_YOUNG",
errmsg: "You must be at least 13 to register",
errdata: null,
},
];
}
const usernameTaken = await db.users.exists({
username: payload.username,
});
if (usernameTaken) {
return [
null,
{
errcode: "USERNAME_TAKEN",
errmsg: `"${payload.username}" is already taken`,
errdata: null,
},
];
}
const user = await db.users.create({
email: payload.email,
username: payload.username,
passwordHash: await hash(payload.password),
dob: payload.dateOfBirth,
});
return [
{ id: user.id, email: user.email, username: user.username } as User,
null,
];
},
);
this.defineAsk(
"updateEmail",
UpdateEmailSchema,
async ({ newEmail, password }) => {
const valid = await verifyPassword(this.currentUserId, password);
if (!valid)
return [
null,
{
errcode: "WRONG_PASSWORD",
errmsg: "Incorrect password",
errdata: null,
},
];
await db.users.updateEmail(this.currentUserId, newEmail);
return [true, null];
},
);
}
}// Frontend: validation errors returned as ActorError, not thrown
const [user, err] = await registrationRef.ask("register", formData);
if (err) {
if (err.errcode === "VALIDATION_ERROR") {
// err.errdata is an array of { path, message } objects from Zod
showFieldErrors(err.errdata as SchemaIssue[]);
} else {
showGlobalError(err.errmsg);
}
return;
}
redirect(`/welcome/${user.username}`);Middleware approach — for validating all asks at the system boundary (e.g., from an external API client):
import { createValidationMiddleware } from "@hotreloads/crux/validation";
actorSystem.use(
createValidationMiddleware({
checkout: z.object({
cartId: z.string().uuid(),
paymentMethodId: z.string(),
}),
applyPromo: z.object({ code: z.string().length(8) }),
}),
);Feature 10 — Request Cancellation
What it does: Every ask() accepts an optional { signal: AbortSignal } option. If the signal fires before the actor replies, the caller's Promise resolves with { errcode: 'ABORTED' } immediately. The actor continues running — only the caller stops waiting.
When to use it: Search-as-you-type (cancel the previous search when a new one starts), React component cleanup (cancel pending asks when the component unmounts), and any operation with user-defined timeouts.
Real-world example: Live search with debounce and cancellation
A search input that queries a SearchActor on every keystroke. Without cancellation, responses arrive out of order — you type "react hooks" and the result for "rea" arrives after the result for "react hooks", showing stale data.
// components/SearchBox.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import { useActorRefs } from "../actors";
export function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const { searchRef } = useActorRefs();
// Keep the latest AbortController so we can cancel the previous search
const ctrlRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
// Cancel the previous in-flight search
ctrlRef.current?.abort();
const ctrl = new AbortController();
ctrlRef.current = ctrl;
setSearching(true);
searchRef
.ask("search", { query }, { signal: ctrl.signal })
.then(([hits, err]) => {
if (err?.errcode === "ABORTED") return; // stale request, ignore
setSearching(false);
if (err) {
console.error(err.errmsg);
return;
}
setResults(hits as SearchResult[]);
});
// Cleanup: cancel the ask if the component unmounts mid-search
return () => ctrl.abort();
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{searching && <Spinner />}
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
);
}Timeout-based cancellation — no AbortController needed:
// If the search actor takes more than 3 seconds, give up and show a fallback
const [results, err] = await searchRef.ask(
"search",
{ query },
{ signal: AbortSignal.timeout(3_000) },
);
if (err?.errcode === "ABORTED") {
showFallbackResults();
return;
}Using useActorAsk with cancellation:
const { call: search, loading } = useActorAsk(searchRef, "search");
const handleSearch = async (query: string) => {
const ctrl = new AbortController();
const [results] = await search({ query }, { signal: ctrl.signal });
// ...
};Feature 11 — Actor Supervision
What it does: Actors can call this.crash(reason) to signal they've entered a broken state. If the actor was registered with a SupervisionConfig, the system automatically destroys the failed instance, waits for an optional backoff delay, creates a fresh replacement, and re-registers it. Live refs queue their asks during the restart window and replay them on the new instance — callers see a brief delay, not a broken ref.
When to use it: Any actor that maintains an external connection or cache that can become permanently corrupted — payment processors, WebSocket connections, database connection pools, token stores.
Real-world example: Payment actor that auto-restarts on gateway failure
Your PaymentActor maintains a connection to a payment gateway SDK. Occasionally the SDK enters an unrecoverable error state — it starts rejecting all calls with GATEWAY_INTERNAL_ERROR. Without supervision, the actor is stuck until a user refreshes. With supervision, it's back in seconds.
// actors/payment-actor.ts
import { Actor } from "@hotreloads/crux";
export class PaymentActor extends Actor {
#gateway: PaymentGateway | null = null;
#consecutiveErrors = 0;
async init(): Promise<void> {
this.#gateway = await PaymentGateway.connect({
apiKey: env.PAYMENT_API_KEY,
});
this.#consecutiveErrors = 0;
this.publish("status", "ready", true);
}
async ask(asktype: string, data: unknown) {
switch (asktype) {
case "charge": {
const { amount, paymentMethodId } = data as ChargePayload;
try {
const result = await this.#gateway!.charge(amount, paymentMethodId);
this.#consecutiveErrors = 0;
return [{ transactionId: result.id, status: "succeeded" }, null];
} catch (e) {
this.#consecutiveErrors++;
// Transient error: return failure, don't restart
if (e instanceof GatewayTimeoutError) {
return [
null,
{
errcode: "PAYMENT_TIMEOUT",
errmsg: "Payment timed out, please try again",
errdata: null,
},
];
}
// Permanent error: the gateway connection is corrupt — restart the actor
if (
e instanceof GatewayCorruptStateError ||
this.#consecutiveErrors >= 5
) {
this.publish("status", "restarting", true);
this.crash(e); // triggers the supervision restart
return [
null,
{
errcode: "PAYMENT_UNAVAILABLE",
errmsg:
"Payment service is restarting, please retry in a moment",
errdata: null,
},
];
}
return [
null,
{
errcode: "PAYMENT_FAILED",
errmsg: (e as Error).message,
errdata: null,
},
];
}
}
}
return [null, this.errunsupportedask(asktype, data)];
}
destroy(): void {
this.#gateway?.disconnect();
this.#gateway = null;
}
}// actors/index.ts — register with supervision
import { ActorSystem } from "@hotreloads/crux";
import type { SupervisionConfig } from "@hotreloads/crux";
const system = new ActorSystem();
const paymentSupervision: SupervisionConfig = {
// How to create a fresh replacement
factory: () => new PaymentActor(system),
// Allow up to 5 restarts within 2 minutes before giving up
maxRestarts: 5,
windowMs: 2 * 60_000,
// Wait before restarting: 1s, 2s, 4s, 8s, 16s...
backoff: "exponential",
initialDelayMs: 1_000,
maxDelayMs: 30_000,
onRestart: (attempt, reason) => {
logger.warn(`PaymentActor restarting (attempt ${attempt})`, reason);
metrics.increment("payment_actor.restart");
},
onPermanentFailure: (reason) => {
logger.error(
"PaymentActor permanently failed — manual intervention required",
reason,
);
alertOncall("PaymentActor down", reason);
metrics.increment("payment_actor.permanent_failure");
},
};
system.registerChild(
"app",
"payment",
new PaymentActor(system),
paymentSupervision,
);// In your checkout flow — the ref survives the restart automatically
const paymentRef = system.actorOfChild<PaymentActorAsks>("app", "payment");
// User clicks "Pay". The payment actor crashes and restarts mid-flight.
// The ask queues up and replays on the fresh actor — the user waits a moment,
// then gets a successful charge result. No broken UI, no manual refresh.
const [receipt, err] = await paymentRef.ask("charge", {
amount: cart.total,
paymentMethodId: selectedCard.id,
});Package Exports
The library uses subpath exports so you only pay for what you import.
| Import path | Contents |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| @hotreloads/crux | Actor, ActorRef, ActorSystem, PersistentActor, middleware types, supervision types, IActorRef, AskOptions |
| @hotreloads/crux/react | useActorSubscribe, useActorAsk |
| @hotreloads/crux/svelte | actorSubscribe, actorAsk |
| @hotreloads/crux/devtools | ActorDevTools class, DevToolsEvent, DiffEntry, AskStatsRow types |
| @hotreloads/crux/devtools/react | ActorDevToolsPanel React component |
| @hotreloads/crux/testing | ActorTestHarness, MockActorRef |
| @hotreloads/crux/bridge | ActorTransport, ActorBridgeHost, ActorBridgeClient, RemoteActorRef |
| @hotreloads/crux/bridge/broadcastchannel | BroadcastChannelTransport |
| @hotreloads/crux/validation | ValidatedActor, createValidationMiddleware, Schema, SchemaIssue |
Peer dependencies: react >= 18 (required for @hotreloads/crux/react and @hotreloads/crux/devtools/react), svelte >= 4 (required for @hotreloads/crux/svelte). Both are optional.
API Reference
Actor
| Member | Description |
| ------------------------------- | ---------------------------------------------------------------------------------- |
| ask(asktype, data) | Override this to handle requests. Return [result, null] or [null, error]. |
| publish(topic, data, retain?) | Broadcast a value. retain: true stores it for late subscribers and getstore(). |
| getstore(topic) | Read the current retained value for a topic. |
| init() | Lifecycle: called after registration. Override to initialize. |
| destroy() | Lifecycle: called on unregistration. Override to clean up. |
| crash(reason?) | Signal an unrecoverable failure — triggers supervision restart if configured. |
| newerror(code, data, msg) | Helper to construct an ActorError. |
ActorSystem
| Member | Description |
| ------------------------------------------------------- | -------------------------------------------------------------------------------- |
| registerChild(widgetid, address, actor, supervision?) | Register an actor. Pass SupervisionConfig to enable auto-restart. |
| unregisterChild(widgetid, address) | Unregister an actor and call its destroy(). |
| actorOfChild<AskMap>(widgetid, address) | Get a typed ActorRef. Creates a pending ref if the actor isn't registered yet. |
| use(middleware) | Register global ask middleware. Returns this for chaining. |
| devtools | Attach an ActorDevTools instance to enable the dev panel. |
ActorRef<AskMap>
| Member | Description |
| ---------------------------------- | ---------------------------------------------------------------------------------- |
| ask(asktype, payload?, options?) | Send a request. Returns Promise<ActorResult>. Accepts { signal: AbortSignal }. |
| subscribe(topic, callback) | Subscribe to a topic. Retained value delivered immediately. |
| unsubscribe(topic) | Remove the subscription for a topic. |
| close() | Unsubscribe everything and release the ref. Call in component cleanup. |
ActorResult<T>
type ActorResult<T = unknown> = [T, null] | [null, ActorError];
interface ActorError {
errcode: string; // machine-readable error identifier
errmsg: string; // human-readable message
errdata: unknown; // structured error data (e.g. validation issues array)
}AskMiddleware
type AskMiddleware = (
ctx: { actorid: string }, // identifies the actor being asked
next: AskHandler, // the next layer in the chain
) => AskHandler;
type AskHandler = (asktype: string, payload: unknown) => Promise<ActorResult>;SupervisionConfig
interface SupervisionConfig {
factory: () => Actor;
maxRestarts?: number; // default: 3
windowMs?: number; // default: 60_000
backoff?: "none" | "fixed" | "exponential"; // default: 'none'
initialDelayMs?: number; // default: 1_000
maxDelayMs?: number; // default: 30_000
onRestart?: (attempt: number, reason: unknown) => void;
onPermanentFailure?: (reason: unknown, actorid: string) => void;
}PersistentActor
| Member | Description |
| -------------------------- | ------------------------------------------------------------- |
| setupPersistence(config) | Call in constructor. Immediately hydrates from storage. |
| getPersistedValue(topic) | Read directly from storage, bypassing the in-memory pubstore. |
| clearPersistence() | Remove all storage keys for this actor. Call on logout. |
ValidatedActor
| Member | Description |
| ------------------------------------- | ------------------------------------------------------------------------- |
| defineAsk(asktype, schema, handler) | Register a typed, validated handler. Handler receives the parsed payload. |
ActorTestHarness<AskMap>
| Member | Description |
| ----------------------------------- | ------------------------------------------------------------------- |
| ask(asktype, payload?, options?) | Typed ask through the real actor pipeline. |
| retained(topic) | Read the current retained value for a topic. |
| collect(topic) | Returns a stable array that grows with each publish. |
| waitForPublish(topic, timeoutMs?) | Resolves on the next publish to the topic (skips retained). |
| system | The underlying ActorSystem — use to register middleware in tests. |
| destroy() | Unregister the actor and clean up. Call in afterEach. |
MockActorRef<AskMap>
| Member | Description |
| -------------------------------- | ------------------------------------------------------------------------- |
| mockAsk(asktype, result \| fn) | Configure a static result or a function (payload) => result. Chainable. |
| emit(topic, data, retain?) | Simulate a publish from the actor. |
| calls | Array of { asktype, payload, result } — all recorded ask calls. |
| wasCalled(asktype) | Returns true if ask() was called with the given asktype. |
| callsFor(asktype) | All recorded calls for a specific asktype. |
| reset() | Clear calls, mocks, subscriptions, and retained values. |
Framework Support
Actors and ActorSystem are pure TypeScript with no DOM or framework dependencies — they work in:
- React (18+) and Next.js (App Router and Pages Router)
- Svelte (4+) and SvelteKit — first-class support via
@hotreloads/crux/svelte(actorSubscribe,actorAsk) - Vue 3 (subscribe in
onMounted, unsubscribe inonUnmounted) - Node.js (for server-side actors, background jobs, API handlers)
- Web Workers (via the
ActorWorker/ActorPureWWbridge)
The @hotreloads/crux/react and @hotreloads/crux/svelte subpaths are the only framework-specific packages. Everything else is universal.
License
MIT
Built by Hotreloads Digital Private Limited
Looking for a technical partner? Contact [email protected]
