npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hotreloads/crux

v1.0.1

Published

Framework-agnostic TypeScript system — separate business logic from UI

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
                      └─────────────────────────────┘ ──► NotificationActor

Installation

npm install @hotreloads/crux

TypeScript 5.4+ is required. No other runtime dependencies.


Table of Contents


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 init

The 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.ts

For 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 auth

This 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.ts

The 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 list

Full 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 object

Quick 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 field

With 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 timeout

Feature 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 in onUnmounted)
  • Node.js (for server-side actors, background jobs, API handlers)
  • Web Workers (via the ActorWorker / ActorPureWW bridge)

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]