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.
ExecuteTransport
Section titled “ExecuteTransport”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>;}Keeping the relay’s extra answer
Section titled “Keeping the relay’s extra answer”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.
Events, on both transports
Section titled “Events, on both transports”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 clientEverything 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.
When no key is available
Section titled “When no key is available”For a cloud-hosted relay the cloud does not publish the node key yet
(mdma #312), so CloudRelay.nodeKey is null and:
client.canSubscribeisfalse— it is a fact about this client, not a constant about the transport;client.eventsandclient.wsthrow, 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.
What the relay transport still cannot do
Section titled “What the relay transport still cannot do”ephemeral, admin, auth, node and cloud throw on a relay client,
naming what is missing.
admin/authwant theadminscope, which theaccount_proofprovider does not grant and a keyholder should never ask for.ephemeralpublishing resolves the author from an owned context identity on the node, which a delegated session is not known to have. Presence still arrives onclient.eventslike 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.
connectCloud
Section titled “connectCloud”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.