Skip to content

Transport

There are two ways to write to a context: ask a node to run the method (sdk.rpc.execute), or hand a relay a signed warrant and have it run the method as you (RelayClient).

They had different call shapes — named vs. positional arguments, T vs. IntentResult<T> — so moving an app from one to the other meant rewriting every call site. createMeroClient makes the transport a property of the connection: choose once, at construction, and nothing after it changes.

import { createMeroClient } from '@calimero-network/mero-js';
// The default. Identical to createMeroJs({ baseUrl }).
const client = createMeroClient({ baseUrl: 'http://localhost:2428' });
// The same app code, through a relay.
const client = createMeroClient({ transport: 'relay', relay: connection.relay });
// ...and from here on, byte for byte the same:
await client.rpc.execute({ contextId, method: 'set', argsJson: { key, value } });

The node shape is canonical and the relay adapts to it, not the other way round: shipped apps already write execute({ contextId, method, argsJson }), so any other choice would be a breaking change wearing an abstraction’s clothes. Omitting transport selects the node, so nothing an existing app does can accidentally pick the relay.

client.rpc on either transport. RpcClient satisfies it as-is.

interface ExecuteTransport {
readonly kind: 'node' | 'relay';
/** Whether this client can subscribe *as configured*. See below. */
readonly canSubscribe: boolean;
execute<T>(params: ExecuteParams): Promise<T>;
executeWithMetadata<T>(params: ExecuteParams): Promise<ExecuteResult<T>>;
migrateMyEntries(contextId: string): Promise<MigrateMyEntriesSummary>;
countMyPending(contextId: string): Promise<number>;
}

A relay also reports the context’s scope root after the run — the cheap way to tell “this changed something” from “this was a no-op”. The node does not report it, so it has no place in the canonical return. It is not discarded:

const { returns, rootHash, transport } = await client.rpc.executeWithMetadata({ contextId, method });

rootHash is absent on the node transport rather than '': “the node does not report this” is not an answer about the root.

A null return stays null on both transports. RpcClient.execute already resolves a null output to null, so normalizing the relay’s null to undefined would make the two disagree in the exact case this adapter exists to make agree.

A relay is a node. The same origin that answers the intents routes also answers /auth/challenge, /auth/token, /sse and /ws, and a keyholder holding an AccountProof<DeviceCert> can login() to it: the account_proof provider grants exactly context:intent, context:query, context:subscribe. Subscribe is in that default grant — not something extra to ask for.

So client.events and client.ws are the same call on both transports, given one input: the relay node’s device signing key.

const client = createMeroClient({
transport: 'relay',
relay: connection.relay,
// learned out of band — from the relay's operator, or a certificate you
// already trust
observe: { nodeKey },
});
client.events.on('event', onEvent); // identical to the node client

Everything else the login needs is defaulted from the RelayClient you already configured: its URL, the author’s account proof, and its device signer. A second copy of those would just be two things that can disagree.

For a cloud-hosted relay the cloud does not publish the node key yet (mdma #312), so CloudRelay.nodeKey is null and:

  • client.canSubscribe is false — it is a fact about this client, not a constant about the transport;
  • client.events and client.ws throw, naming the missing key and #312.

Nothing is stubbed and nothing falls back to “some node”: an app observing a node it never chose, under a credential it never granted, finds out only when the two disagree.

Branch on the capability instead of on the failure:

if (client.canSubscribe) {
client.events.on('event', onEvent);
}

Pass relayNodeKey to connectCloud if you know the key today. When #312 lands, connectCloud picks it up from the relay row and unchanged callers start observing — the call site does not change either way.

ephemeral, admin, auth, node and cloud throw on a relay client, naming what is missing.

  • admin / auth want the admin scope, which the account_proof provider does not grant and a keyholder should never ask for.
  • ephemeral publishing resolves the author from an owned context identity on the node, which a delegated session is not known to have. Presence still arrives on client.events like any other context event, so the read half is available by filtering there.

Reads have the same shape of gap as before. connectCloud is explicit that it does not read state — a relay-transport app reads from a node it can query, or from its own projection of the events it now receives.

The cloud connection already made the transport decision, so it hands back a ready client over the very same relay — one relay, one nonce sequence:

const connection = await connectCloud({ ... });
await connection.client.rpc.execute({ contextId, method: 'set', argsJson });

connection.relay and connection.execute are unchanged and remain the direct path for a caller that wants the IntentResult in hand.

connectCloud always configures observe on that client, with whatever node key is known: yours if you passed relayNodeKey, otherwise the relay row’s — null until mdma #312. One code path, so the hosted case starts observing the day the field appears.