Skip to content

About

React hooks and provider for building apps on the Calimero node network.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@calimero-network/mero-react

React bindings for @calimero-network/mero-js — the Calimero Network SDK.

No external UI framework. No styled-components. No axios. A provider, 44 hooks, storage helpers, and optional headless utility components (ConnectButton, LoginModal).

Installation

pnpm add @calimero-network/mero-react

Peer dependencies: react ^18 || ^19, react-dom ^18 || ^19. mero-js is a bundled dependency — no separate install needed.

Quick start

import { MeroProvider, useMero, useExecute, useSubscription, AppMode } from '@calimero-network/mero-react';

function App() {
  return (
    <MeroProvider mode={AppMode.SingleContext} packageName="com.calimero.my-app">
      <MyApp />
    </MeroProvider>
  );
}

function MyApp() {
  const { isAuthenticated, connectToNode, logout, contextId, contextIdentity } = useMero();

  if (!isAuthenticated) {
    return <button onClick={() => connectToNode('http://localhost:4001')}>Connect</button>;
  }

  return <Dashboard />;
}

function Dashboard() {
  const { contextId, contextIdentity } = useMero();
  const { execute, loading } = useExecute(contextId, contextIdentity);
  const [items, setItems] = useState([]);

  // Real-time updates via SSE
  useSubscription(
    contextId ? [contextId] : [],
    () => fetchItems(),
  );

  const fetchItems = async () => {
    const data = await execute('list');
    if (data) setItems(data);
  };

  const addItem = async (title: string) => {
    await execute('add', { title });
    await fetchItems();
  };

  return (
    <div>
      {loading && <p>Loading...</p>}
      {items.map(item => <div key={item.id}>{item.title}</div>)}
      <button onClick={() => addItem('New item')}>Add</button>
    </div>
  );
}

API reference

<MeroProvider>

Wraps your app with a MeroJs instance, auth state, and SSE connectivity.

<MeroProvider
  mode={AppMode.SingleContext}    // required
  packageName="com.calimero.my-app"  // for package-based apps
  timeoutMs={30000}                  // optional, default 30s
>
  {children}
</MeroProvider>

Props (MeroProviderConfig & { children }):

Prop Type Required Description
mode AppMode Yes SingleContext, MultiContext, or Admin
packageName string No Package name for registry/node lookup
packageVersion string No Specific version (defaults to latest)
registryUrl string No Registry URL override
timeoutMs number No HTTP request timeout (default 30000)
allowedNodeUrls string[] No Origins the OAuth callback may authenticate against. The initiated node is always trusted; this allowlist additionally permits direct-callback entry. A callback node_url matching neither is rejected.
tokenStore TokenStore No Pluggable access/refresh token store. Defaults to localStorage. Pass a MemoryTokenStore or cookie-backed store for sensitive deployments (localStorage tokens are XSS-readable).

Modes and their permissions:

Mode Permissions Use case
SingleContext context:execute Apps that work with one context
MultiContext context:create, context:list, context:execute Apps managing multiple contexts
Admin admin Admin dashboards, dev tools

Auth flow: when connectToNode(url) is called, the provider redirects to the node's auth page. After login, the node redirects back with tokens in the URL hash. The provider processes these once (StrictMode-safe via ref) and sets isAuthenticated = true. The callback's node_url is validated against the node login was initiated with (or allowedNodeUrls); a node_url matching neither is rejected.

Online detection: the provider opens an SSE connection to the node after auth. isOnline reflects the SSE connection state — no polling.

useMero()

Access the MeroJs instance, auth state, and actions.

const {
  mero,             // MeroJs | null — the SDK instance
  isAuthenticated,  // boolean
  isOnline,         // boolean — SSE connection state
  isLoading,        // boolean — initial session restore
  nodeUrl,          // string | null
  applicationId,    // string | null — node: from the auth callback; account: see below
  contextId,        // string | null — from auth callback
  contextIdentity,  // string | null — executor public key from auth callback
  connectToNode,    // (url: string) => void — starts auth redirect
  admin,            // AdminApiClient | null — the session's admin: node admin on a node, account admin on an account
  logout,           // () => void — clears tokens and state
} = useMero();

Every hook in this package goes through that admin, never the raw mero.admin: on a node it is the node's own admin API; on an account (delegated session) it is the account admin, whose reads go to the relay and whose writes are the account's own form of the call (invitations signed with the device key, joins through the admit route, group changes as governance ops under warrants). An app gets the same result from useCreateNamespaceInvitation, useJoinNamespace, useAddGroupMembers and the rest on a node and on an account; a call an account has no form of (useUpgradeGroup, useDeleteNamespace, useJoinGroup, ...) surfaces NotForAccountError through error.

How an account (delegated session) learns its applicationId: packageName decides. When the app passes one, the id is derived from the registry: sha256(borsh((package, signerId))) of the newest non-yanked bundle, exactly as merod computes it at install, so a brand-new account can found its first namespace (admin.createNamespace({ applicationId })) and an account already used in other apps is never handed one of THEIR ids. The account's own contexts on the relay are consulted only when the app passes no packageName (or the registry did not answer, after one console.warn): one distinct application across them is the answer; several leaves it null on purpose (nothing says which the tab is for). When the registry lists the package under more than one publisher, it stays null. applicationIdForBundle and resolveApplicationIdFromRegistry are exported for apps that need the same derivation.

On an account, admin.createContext(request) (and useCreateContext) signs a creation warrant the relay acts on: applicationId, groupId, serviceName (which service of a multi-service bundle, e.g. mero-docs' registry), name, contextSeed and initializationParams are all carried into it; identitySecret is not, since the account itself is the new context's member identity.

Through mero you access the full MeroJs API:

// Admin API (flat methods, NOT nested)
await mero.admin.healthCheck();
await mero.admin.getContexts();
await mero.admin.getContext(contextId);
await mero.admin.getContextIdentitiesOwned(contextId);
await mero.admin.listApplications();
await mero.admin.getApplication(appId);
await mero.admin.installApplication(request);
await mero.admin.createContext(request);
await mero.admin.uploadBlob(request);
await mero.admin.getPeersCount();

// Auth API
await mero.auth.getProviders();
await mero.auth.generateTokens(request);
await mero.auth.refreshToken(request);

// RPC
await mero.rpc.execute({ contextId, method, argsJson, executorPublicKey });

// SSE events
mero.events.connect();
mero.events.subscribe(contextIds);
mero.events.on('event', handler);

// Tokens
mero.getTokenData();        // { access_token, refresh_token, expires_at } | null
mero.isAuthenticated();     // boolean

useExecute(contextId, executorId)

Wraps mero.rpc.execute() with loading/error state. Unmount-safe.

const { execute, loading, error } = useExecute(contextId, contextIdentity);

// Generic typed
const todos = await execute<Todo[]>('list');
await execute('add', { title: 'Buy milk' });
await execute('toggle', { id: '1' });
Return Type Description
execute <T>(method, params?) => Promise<T | null> Call a contract method
loading boolean Request in flight
error Error | null Last error

useSubscription(contextIds, callback)

Manages SSE event subscription lifecycle. StrictMode-safe — connects once per MeroJs instance, cleans up on unmount.

useSubscription(
  contextId ? [contextId] : [],
  (event) => {
    console.log('Context event:', event.contextId, event.data);
    refreshData();
  },
);
Param Type Description
contextIds string[] Context IDs to subscribe to (empty array = no subscription)
callback (event: SseEventData) => void Called on each context event

The SSE connection is shared — multiple useSubscription hooks reuse the same connection. The first one to mount calls connect(), subsequent ones just add handlers.

useContexts(applicationId?)

Fetches contexts from the node, optionally filtered by application ID.

const { contexts, loading, error, refetch } = useContexts(applicationId);

// contexts: Array<{ contextId: string; applicationId: string }>

Blob hooks

useBlobInfo / useBlobUrl / useUploadBlob read and write the node's blob store. The reads take an optional contextId, and it is what picks the mode:

  • without contextId — local-only. The node answers from its own blob store immediately, or 404s.
  • with contextId — network discovery. The node probes that context's peers (availability nodes first) for a holder.

Discovery is slow: core bounds the search by a ~30s deadline, and for useBlobUrl the byte transfer is on top of that. loading is real UI state on these hooks, not decoration.

// Presence and size, no download — ask this before pulling something large.
const { info, notFound, loading, error } = useBlobInfo(blobId, { contextId });
// info: { blobId, size, hash?, mimeType?, source?: 'local' | 'peer' } | null

// Bytes as an object URL, revoked for you on unmount and on id/context change.
const { url } = useBlobUrl(blobId, { contextId, type: 'image/png' });
return url ? <img src={url} /> : null;

// Upload. Pass contextId to announce the blob so the context's peers can
// discover it later.
const { uploadBlob, loading, error } = useUploadBlob();
const result = await uploadBlob({ data: bytes, contextId });

A null/undefined blobId fetches nothing, like the other string | null read hooks. hash and mimeType are genuinely optional — a peer probe carries only presence and size, so a discovery-sourced hit has neither; read info.source to tell a local answer from a peer one. notFound marks the node's legitimate "no holder" 404 apart from a transport failure; error is set for both.

Storage helpers

Persist/read node URL, application ID, context ID, and context identity in localStorage.

import {
  getNodeUrl, setNodeUrl, clearNodeUrl,
  getApplicationId, setApplicationId, clearApplicationId,
  clearAllStorage,
} from '@calimero-network/mero-react';

These are used internally by MeroProvider but exported for apps that need direct access.

Re-exports from mero-js

mero-react re-exports everything from mero-js via export * from '@calimero-network/mero-js'. Any new API added to mero-js is automatically available from mero-react — no manual sync needed.

// All of these work from a single import
import {
  MeroProvider, useMero, useExecute, useSubscription,  // react
  MeroJs, RpcClient, SseClient, WsClient,              // core
  parseAuthCallback, buildAuthLoginUrl,                  // auth helpers
  LocalStorageTokenStore, MemoryTokenStore,              // token stores
} from '@calimero-network/mero-react';

Theming

ConnectButton and LoginModal default to the green Calimero palette used in admin-dashboard, tauri-app, and app-registry. Pass nothing for the default — provide a partial MeroTheme to override any subset.

import { ConnectButton } from '@calimero-network/mero-react';

// Default green palette — no theme prop needed
<ConnectButton />

// Override only what you want; the rest stay default
<ConnectButton
  theme={{
    primary: '#ff4081',
    primaryHover: '#e91e63',
    primaryText: '#ffffff',
  }}
/>

The same theme is forwarded to the embedded LoginModal and is also exposed as CSS variables on the component root, so a global stylesheet works too:

:root {
  --mero-accent: #ff4081;
  --mero-accent-hover: #e91e63;
}

MeroTheme keys (all optional): primary, primaryHover, primaryText, background, backgroundSecondary, backgroundTertiary, border, text, textSecondary, error, overlay, radius. Defaults: #a5ff11 / #8ed40d / #0d1117 / #161b22 / #1c2128 / #30363d / #e6edf3 / #8b949e / #ff6b6b / rgba(0,0,0,0.75) / 8px.

Helpers: defaultMeroTheme (the full default palette), resolveMeroTheme(partial?) (merges a partial with defaults), and themeToCssVars(resolved) (returns a CSSProperties map of --mero-* variables for applying to your own container) are exported for advanced use.

Browser support: hover-state and modal tints use CSS color-mix(), which requires Chrome 111+, Safari 16.2+, or Firefox 113+ (all 2023). On older browsers the bundled styles.css falls back to the default green rgba() for the button hover; the modal renders without subtle tints but is otherwise fully functional.

<ConnectButton> props

// Square 40×40 icon button — logo only, no text
<ConnectButton logoOnly />

// Custom label (shorthand: bare string overrides the disconnected label)
<ConnectButton label="Sign in with Calimero" />

// Per-state label overrides
<ConnectButton
  label={{
    connect: 'Sign in',
    connected: 'Signed in',
    reconnecting: 'Reconnecting…',
  }}
/>
Prop Type Default Notes
connectionType ConnectionType | CustomConnectionConfig Remote Custom connects straight to the given URL, skipping the modal. Every other value opens the LoginModal (node discovery + manual URL entry).
theme MeroTheme — Partial token override.
logoOnly boolean false Render only the Calimero logo (square button). The label is still announced via aria-label.
label string | { connect?, connected?, reconnecting? } — Override default labels. Bare string targets the disconnected state.
className / style — — Forwarded to the inner <button>.
cloud boolean true The modal has two tabs: Node (discovery + URL, default) and Cloud (sign in with a Calimero account by enrolling at the wallet). false removes the Cloud tab. A page coming back from the wallet opens the modal on Cloud automatically.
accountDefaults { walletUrl?: string } — Local development only: enrol at a wallet other than the hosted one.

<LoginModal> and the Cloud tab

An app that mounts the modal itself gets the same two tabs with nothing wired: by default LoginModal sources its Cloud tab from useAccountEnrolment() internally, so "Enrol with your account" works and a page coming back from the wallet is completed (and lands on the Cloud tab) whether or not the modal is open. It must sit inside MeroProvider.

// Node + Cloud tabs, account sign-in included (default)
<LoginModal isOpen={open} onConnect={connectToNode} onClose={() => setOpen(false)} />

// Node dialog only, no tabs — the pre-Cloud behaviour
<LoginModal cloud={false} isOpen={open} onConnect={connectToNode} onClose={() => setOpen(false)} />

// Your own enrolment (what ConnectButton does): the object is used verbatim
<LoginModal cloud={{ onEnrol, note, walletUrl, customWallet }} initialTab="cloud" ... />
cloud Cloud tab Enrolment callback read by
omitted shown, sourced by the modal the modal
false none (no tabs) nobody here
object shown, the caller's values the caller

initialTab defaults to 'node'; with cloud omitted it defaults to 'cloud' on the load coming back from the wallet. An explicit initialTab wins.

The caller owns isOpen. To have the modal open by itself on the way back from the wallet (as ConnectButton does), seed its open state with isReturningFromWallet(), which peeks at the fragment without consuming it: const [open, setOpen] = useState(isReturningFromWallet);. The account flow without any of these is useAccountEnrolment() (returns goToWallet, note, returning, walletUrl, customWallet); its UI is <AccountSignInPanel>.

Enums

AppMode.SingleContext   // 'single-context'
AppMode.MultiContext    // 'multi-context'
AppMode.Admin           // 'admin'

ConnectionType.Remote          // 'remote' — opens the LoginModal (discovery + manual URL)
ConnectionType.Custom          // 'custom' — skip the modal, connect to a fixed URL
ConnectionType.RemoteAndLocal  // deprecated, same as Remote
ConnectionType.Local           // deprecated, same as Remote

Types

import type {
  MeroContextValue,        // useMero() return type
  MeroProviderConfig,      // MeroProvider props (without children)
  MeroProviderProps,       // MeroProvider props (with children)
  CustomConnectionConfig,  // { type: ConnectionType.Custom, url: string }
  AppContext,              // { contextId, executorId, applicationId }
  ExecutionResult,         // { success, result?, error? }
  ApplicationContextRecord,// { contextId, applicationId }
  ContextDiscoveryOptions, // options for useContextDiscovery
  ContextDiscoveryState,   // return type of useContextDiscovery
  MeroTheme,               // partial theme override for ConnectButton / LoginModal
  ResolvedMeroTheme,       // fully populated theme returned by resolveMeroTheme()
} from '@calimero-network/mero-react';

Full exports list

// Provider & context (mero-react)
MeroProvider, useMero, MeroContext

// Components (mero-react)
ConnectButton, LoginModal

// Theming (mero-react)
MeroTheme, ResolvedMeroTheme, defaultMeroTheme, resolveMeroTheme, themeToCssVars

// Enums (mero-react)
AppMode, ConnectionType

// Hooks (mero-react)
useExecute, useSubscription
useContexts, useApplicationContexts, useContextGroup, useContextDiscovery
useCreateContext, useDeleteContext, useJoinContext
useGroupInfo, useGroupMembers, useGroupContexts, useGroupInvitations, useGroupCapabilities
useJoinGroup, useDeleteGroup, useAddGroupMembers, useRemoveGroupMembers
useSyncGroup, useReparentGroup, useSubgroups
useUpgradeGroup, useGroupUpgradeStatus, useRetryGroupUpgrade
useSetGroupMetadata, useSetMemberMetadata, useSetContextMetadata
useGroupMetadata, useMemberMetadata, useUpdateMemberRole
useSetDefaultCapabilities, useDefaultCapabilities
useSetSubgroupVisibility, useSubgroupVisibility, useSetTeeAdmissionPolicy
useDetachContextFromGroup
useNamespaces, useNamespace, useNamespaceGroups, useNamespaceIdentity
useNamespacesForApplication, useCreateNamespace, useDeleteNamespace
useJoinNamespace, useRedeemInvitation, useCreateNamespaceInvitation, useCreateGroupInNamespace
useBlobInfo, useBlobUrl, useUploadBlob

// Types (mero-react)
MeroContextValue, MeroProviderConfig, MeroProviderProps
CustomConnectionConfig, AppContext, ExecutionResult
ApplicationContextRecord, ContextDiscoveryOptions, ContextDiscoveryState
BlobHookOptions, UseBlobUrlOptions, MutationOptions

// Storage (mero-react)
localStorageTokenStorage
getNodeUrl, setNodeUrl, clearNodeUrl
getApplicationId, setApplicationId, clearApplicationId
getContextId, setContextId, clearContextId
getContextIdentity, setContextIdentity, clearContextIdentity
clearAllStorage

// Everything from @calimero-network/mero-js (auto re-exported)
MeroJs, createMeroJs, MeroJsConfig, TokenData
RpcClient, RpcError, ExecuteParams
SseClient, SseEventData, WsClient, WsEventData
AuthApiClient, AdminApiClient
LocalStorageTokenStore, MemoryTokenStore, TokenStore
parseAuthCallback, buildAuthLoginUrl, AuthCallbackResult, AuthLoginOptions
WebHttpClient, HttpClient, HTTPError
// ...and all other mero-js exports

License

MIT

About

React hooks and provider for building apps on the Calimero node network.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages