JORVEL

State & event bus

Two primitives cover almost every cross-remote communication pattern: a typed event bus (@jorvel/event-bus) and a singleton store registry (@jorvel/state). Both are pinned to globalThis so duplicate bundles still observe the same instance — the federation share-scope is the fast path, globalThis is the safety net.

Picking the right primitive

  • Event bus — fire-and-forget cross-remote signals (analytics ping, cart updated, user logged in).
  • SimpleStore — a single value with subscribers (current theme, locale, feature flags).
  • Store + reducer — non-trivial state machines with auditable actions (cart, multi-step forms).
  • Selectors — derive view-models from any store without recomputing.

Event bus

ts
import { getEventBus } from '@jorvel/event-bus';

interface Events {
  'user:login':    { userId: string };
  'cart:updated':  { count: number };
  'theme:changed': 'light' | 'dark';
}

const bus = getEventBus<Events>();

// Subscribe (returns unsubscribe)
const off = bus.on('cart:updated', (p) => console.log(p.count));

// Subscribe + replay last value (great for late-mounting subscribers)
bus.on('theme:changed', applyTheme, { replay: true });

// Listen exactly once
bus.once('user:login', (p) => audit.track('first-login', p));

// Wildcard — every event (logging / devtools)
const offAny = bus.onAny((event, payload) => devtools.push({ event, payload }));

// Fire
bus.emit('cart:updated', { count: 3 });

// Tear down
off();
offAny();
bus.clear('cart:updated');     // wipe one event
bus.clear();                   // wipe everything (tests)

bus is a singleton — any remote that imports @jorvel/event-bus shares the same instance because the package is declared as a singleton in federation config. A handler that throws is caught (errorHandler) so a single bad subscriber cannot abort delivery to the rest.

Simple store

ts
import { getSimpleStore } from '@jorvel/state';

const auth = getSimpleStore<{ user: User | null }>('auth', { user: null });
auth.subscribe((s) => render(s));
auth.set({ user: { id: '1', email: 'x@y.z' } });

Redux-style store

ts
import { getStore } from '@jorvel/state';

type State = { count: number };
type Action = { type: 'inc' } | { type: 'set'; value: number };

const store = getStore<State, Action>('counter', {
  initial: { count: 0 },
  reducer: (s, a) => {
    switch (a.type) {
      case 'inc': return { count: s.count + 1 };
      case 'set': return { count: a.value };
    }
  },
});
store.dispatch({ type: 'inc' });

Atoms (Jotai-style)

For fine-grained, bottom-up state, use atom + derivedAtom. Atoms are module-scoped values you import; React bindings (useAtom / useAtomValue / useSetAtom) live in @jorvel/state/react.

ts
import { atom, derivedAtom } from '@jorvel/state';

export const count = atom(0);
export const doubled = derivedAtom([count], ([c]) => c * 2);  // recomputes on count change

count.set(5);          // or count.set((p) => p + 1)
doubled.get();         // 10
tsx
import { useAtom, useAtomValue, useSetAtom } from '@jorvel/state/react';
import { count, doubled } from './atoms.js';

function Counter() {
  const [n, setN] = useAtom(count);
  const d = useAtomValue(doubled);          // re-renders only when doubled changes
  return <button onClick={() => setN((p) => p + 1)}>{n}{d}</button>;
}

function ResetButton() {
  const setN = useSetAtom(count);           // setter only — does NOT re-render on change
  return <button onClick={() => setN(0)}>reset</button>;
}

derivedAtom takes explicit dependencies — the graph stays static and predictable, and dep subscriptions attach only while something is listening.

Memoized selectors

createSelector composes input selectors and caches the last result. Use it to derive view models that should only recompute when their dependencies change.

ts
import { createSelector, createStructuredSelector, shallowEqual, createSelectorWith } from '@jorvel/state';

const selectUser = (s: AppState) => s.user;
const selectCart = (s: AppState) => s.cart;

const selectCartSummary = createSelector(
  selectCart,
  (cart) => ({ count: cart.items.length, total: cart.items.reduce((a, b) => a + b.price, 0) }),
);

// Custom equality (shallow) — useful when a selector returns a fresh object.
const selectDashboard = createSelectorWith(
  { equalityFn: shallowEqual },
  selectUser,
  selectCart,
  (u, c) => ({ name: u.name, count: c.items.length }),
);

// Structured selectors return an object with the named results.
const selectHeader = createStructuredSelector({
  user: selectUser,
  cartCount: (s) => s.cart.items.length,
});

Store middleware

Compose middleware around the store to log, throttle, persist, or defer async work. thunkMiddleware turns function actions into (dispatch, getState) callbacks; loggerMiddleware prints every dispatch; persistenceMiddleware writes the latest state to your storage adapter with optional throttling.

ts
import {
  createStoreWithMiddleware,
  thunkMiddleware,
  loggerMiddleware,
  persistenceMiddleware,
} from '@jorvel/state';

const store = createStoreWithMiddleware(
  { user: null, count: 0 },
  reducer,
  [
    thunkMiddleware(),
    loggerMiddleware({ label: 'shell', printState: true }),
    persistenceMiddleware({
      save: (s) => localStorage.setItem('app', JSON.stringify(s)),
      throttleMs: 250,
    }),
  ],
);

// Thunk
store.dispatch(({ dispatch }) => {
  fetch('/me')
    .then((r) => r.json())
    .then((user) => dispatch({ type: 'user.set', user }));
});

Persistence round-trips custom serializers

persistStore / persistSimpleStore write an envelope of { v, state } where state is the serialized payload string, so a custom serialize/deserializepair (superjson, Map, Date) round-trips exactly. On detach they flush any pending debounced write, and they dedupe the hydration echo so rehydrating doesn't re-trigger a save. migrate runs only when the stored version is below the current one — never on a downgrade.

Cross-tab sync

syncStore / syncSimpleStore (from @jorvel/state/sync) mirror state across same-origin tabs over a BroadcastChannel. Each local change is broadcast; incoming updates are applied without re-broadcast (origin-tagged to avoid echo loops). It is a no-op where BroadcastChannel is unavailable (SSR), and pairs with persistence — persist rehydrates a fresh tab, sync keeps open tabs in step.

ts
import { getSimpleStore } from '@jorvel/state';
import { syncSimpleStore } from '@jorvel/state/sync';

const cart = getSimpleStore('cart', { items: [] });
const stop = syncSimpleStore(cart, { channel: 'cart' }); // returns a detach fn

// cart.set(...) in one tab now updates every other tab on the 'cart' channel.

Schema-validated events

Attach validators to the event bus to reject malformed payloads at the source. Any object with a parse(input) method works (Zod, Valibot, ArkType, custom).

ts
import { getEventBus, attachSchemaRegistry } from '@jorvel/event-bus';
import { z } from 'zod';

interface Events {
  'cart:add': { sku: string; qty: number };
}

const bus = getEventBus<Events>();
attachSchemaRegistry(bus, {
  'cart:add': z.object({ sku: z.string().min(1), qty: z.number().int().positive() }),
}, { onInvalid: 'throw', log: (event, err) => console.warn(event, err) });

bus.emit('cart:add', { sku: 'A', qty: 2 });       // ok
bus.emit('cart:add', { qty: 1 } as never);        // throws — sku missing

Feature flags

InMemoryFlags is a built-in provider for local dev / tests. fromVendor() wraps any duck-typed SDK (LaunchDarkly, Flagsmith, Statsig, …) into the common FeatureFlagAdapter interface. Register once at boot, then read from anywhere with isFeatureEnabled() / featureVariation().

ts
import {
  InMemoryFlags,
  setFeatureFlags,
  isFeatureEnabled,
  featureVariation,
  fromVendor,
} from '@jorvel/runtime';

// Local / tests
setFeatureFlags(new InMemoryFlags({
  flags: { newOnboarding: true, theme: 'dark' },
  overrides: { 'u-vip': { newOnboarding: true } },
}));

// Vendor (LaunchDarkly example — duck-typed, no LD dep needed)
// const client = LD.init(process.env.LD_KEY!);
// setFeatureFlags(fromVendor(client, {
//   toClientContext: (ctx) => ({ kind: 'user', key: ctx?.userId ?? 'anon' }),
// }));

if (isFeatureEnabled('newOnboarding', { userId: user.id })) renderOnboarding();
const theme = featureVariation('theme', 'light', { userId: user.id });

Cross-tab sync

Mirror emissions across same-origin tabs with a BroadcastChannel adapter. Echoes from the originating tab are suppressed automatically.

ts
import { connectBroadcast } from '@jorvel/event-bus';

const conn = connectBroadcast(bus, {
  channelName: 'jorvel:cart',
  filter: (event) => event !== 'cart:internal',   // keep some events tab-local
});

// later
conn.disconnect();

SSR hydration

Serialize state on the server, rehydrate on the client. The serialization helper escapes </script> sequences and accepts a CSP nonce so a strict policy still admits the payload.

ts
// server.ts
import { serializeState } from '@jorvel/ssr';
import { buildCsp, generateNonce } from '@jorvel/security';

const nonce = generateNonce();
const initial = await loadInitialState(request);
const head = serializeState(initial, { key: 'app', nonce });

response.setHeader('Content-Security-Policy', buildCsp({ nonce }));
const html = baseHtml.replace('</head>', head + '</head>');

// bootstrap.tsx
import { hydrateState, clearHydratedState } from '@jorvel/ssr';

const state = hydrateState<InitialState>('app');
if (state) primeStore(state);
clearHydratedState('app');   // free the <script> tag once consumed

Recipes

Sharing auth between host and remote

libs/auth-store/src/index.ts
ts
import { getSimpleStore } from '@jorvel/state';
import { getEventBus } from '@jorvel/event-bus';

export interface Session { userId: string; roles: string[] }

export const sessionStore = getSimpleStore<Session | null>('session', null);

// Mirror state to the bus so latecomers can pick it up.
const bus = getEventBus<{ 'auth:session': Session | null }>();
sessionStore.subscribe((s) => bus.emit('auth:session', s));

Time-travel debugging in dev

ts
import { getStore } from '@jorvel/state';

const counter = getStore('counter', { count: 0 }, reducer);

if (process.env.NODE_ENV !== 'production') {
  const history: unknown[] = [counter.getState()];
  counter.subscribe((s) => history.push(structuredClone(s)));
  (window as any).__store = { counter, history, rewind: (i: number) => counter.replaceState(history[i] as never) };
}

Resetting between tests

ts
import { _resetStore, _resetSimpleStore } from '@jorvel/state';
import { _resetEventBus } from '@jorvel/event-bus';

afterEach(() => {
  _resetStore();
  _resetSimpleStore();
  _resetEventBus();
});

RSC-compatible server store

Populate a store on the server, dehydrate() it into the HTML, and hydrate() on the client so the first render matches — no refetch, no flash.

ts
import { createServerStore, dehydrateAll, serializeState, createHydratedStore } from '@jorvel/state';

// server
const cart = createServerStore({ items: [] });
const html = `<script>window.__JORVEL_STATE__ = ${serializeState(dehydrateAll({ cart }))}</script>`;

// client (seeds from the embedded snapshot, else the fallback)
const cart = createHydratedStore('cart', { items: [] });

The server side is hook-free (safe in RSC / edge handlers); the client gets get/set/subscribe. serializeState escapes </script>.