Skip to content

NATS transport

NatsTransport is the production-side implementation of ITransportAdapter. It is what every Host Service runtime uses to talk to NATS, and what the browser variant (createBrowserNatsTransport) wraps. This page covers root prefixing, the semantic-to-NATS subject grammar, JSON encoding, and the INatsLikeConnection adapter that lets the same class work with both @nats-io/nats-core (Node) and nats.ws (browser).

Constructor and the INatsLikeConnection boundary

ts
new NatsTransport(connection: INatsLikeConnection, root: string)
new NatsTransport(connection: INatsLikeConnection, options: INatsTransportOptions)

INatsLikeConnection (NatsTransport.ts:20-31) is a minimal NATS-shaped interface:

ts
interface INatsLikeConnection {
  publish(subject: string, data?: Uint8Array): void;
  subscribe(subject: string, options: { callback: (err, msg) => void }): INatsLikeSubscription;
  isClosed(): boolean;
  drain(): Promise<void>;
  close(): Promise<void>;
}

This abstraction lets NatsTransport work with whichever NATS client wrapper the environment provides. In Node, the runner constructs a connection via @nats-io/nats-core. In the browser, createBrowserNatsTransport uses nats.ws. Test harnesses can stub the connection to drive the transport without a real broker.

Use createTransport (projects/matrix-3/packages/core/src/transport/createTransport.ts) as the convenience constructor:

ts
import { createTransport } from '@open-matrix/core';
import { connect } from '@nats-io/nats-core';

const connection = await connect({ servers: 'nats://127.0.0.1:4222' });
const transport = createTransport({
  natsConnection: connection as unknown as INatsLikeConnection,
  root: 'COM.NIMBLETEC.RICHARD-SANTOMAURO',
});

Root prefix

Every subject NatsTransport publishes is prefixed with the authority root. Root validation (NatsTransport.ts:107-126):

ts
static readonly ROOT_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/;

Roots are DNS-style (alphanumeric, hyphens, dots, underscores). Examples in the tree:

  • COM.NIMBLETEC.RICHARD-SANTOMAURO — derived from email richard.santomauro@nimbletec.com.
  • space.alt.stories.ghost-stories.funny — derived from a public Space path.
  • SPACE.SPC_7F3A9B2C — derived from a Space identity.

Constructing a NatsTransport with a root that fails the pattern throws synchronously.

Semantic to NATS subject grammar

Application-level code uses semantic topics (mount/$inbox, mount/$events, $replies.<cid>). The transport translates them to wire subjects with semanticToNats(topic) (NatsTransport.ts:267-350).

Semantic topicWire subject
system.llm/$inbox<root>.system.llm.$inbox
system.llm/$events<root>.system.llm.$events
system.llm/$exit<root>.system.llm.$exit
$replies.corr-123<root>.$reply.corr-123
system.llm/#<root>.system.llm.> (wildcard)
#<root>.> (wildcard everything under our root)
targetRoot/system.llm/$inboxtargetRoot.system.llm.$inbox (cross-root)
generic.topic<root>.generic.topic

Cross-root subjects use targetRoot/localTopic syntax — the leading slash signals "this is for a different authority root."

The reverse, natsToSemantic(wireTopic) (NatsTransport.ts:362-422), turns a wire subject back into a semantic topic. Used when receiving messages, when reporting wire info, and in the federation adapter.

JSON encoding

By default (jsonMode: true, NatsTransport.ts:133), payloads are encoded with JSON.stringify + TextEncoder and decoded with TextDecoder + JSON.parse. The wire payload is always UTF-8 bytes. Payloads that aren't JSON-serializable (functions, circular refs, BigInt) throw at publish time.

You can disable JSON mode by passing jsonMode: false — useful when bridging non-JSON payloads (binary blobs, protobuf). The handler receives a Uint8Array directly.

Credentials

ts
new NatsTransport(connection, {
  root: '<root>',
  user: 'me',
  pass: 'shh',
});

new NatsTransport(connection, {
  root: '<root>',
  credentials: { user: 'me', pass: 'shh' },
});

Credentials are stored on the transport but the connection itself must already be authenticated with them. The transport does not authenticate; it just remembers the principal. Used by upstream code that needs to know which user this transport is acting as.

Lifecycle

ts
transport.subscribe(topic, handler) → unsub function
transport.publish(topic, payload?)
transport.disconnect() → drains then closes connection

disconnect() (NatsTransport.ts:235-255):

  1. Clears the JWT refresh timer if createBrowserNatsTransport set one.
  2. Unsubscribes every active subscription.
  3. Calls connection.drain() (NATS-protocol clean shutdown).
  4. Calls connection.close().

Failure modes during disconnect are non-fatal — a half-shut connection is fine to leak past the end of a process.

Error and unhandled-message hooks

ts
new NatsTransport(connection, {
  root,
  onError: (err) => console.error('nats error', err),
  onUnhandledMessage: (topic, payload) => console.warn('unhandled', topic, payload),
});

onError is called when the underlying connection reports an error. onUnhandledMessage is called when a message arrives on a wire subject for which no local handler exists (multiple transports sharing a connection — uncommon but supported).

getWireTopicInfo

ts
const info = transport.getWireTopicInfo('system.llm/$inbox');
// {
//   appTopic: 'system.llm/$inbox',
//   wireTopic: 'COM.NIMBLETEC.RICHARD-SANTOMAURO.system.llm.$inbox',
//   prefix: 'COM.NIMBLETEC.RICHARD-SANTOMAURO',
//   root: 'COM.NIMBLETEC.RICHARD-SANTOMAURO',
//   sessionId: undefined,
// }

Used for debugging and when displaying actor addresses in tooling. Mirrored on InMemoryTransport for parity (with prefix: '').

Worked example

ts
import { createTransport, MatrixRuntime, MatrixActor } from '@open-matrix/core';
import { connect } from '@nats-io/nats-core';

const connection = await connect({ servers: 'nats://127.0.0.1:4222' });
const transport = createTransport({
  natsConnection: connection as unknown as INatsLikeConnection,
  root: 'COM.NIMBLETEC.RICHARD-SANTOMAURO',
});

const runtime = new MatrixRuntime({ transport });

class Echo extends MatrixActor {
  static accepts = { 'echo': { description: 'Echo', schema: { msg: { type: 'string', description: 'msg' } }, returns: { msg: { type: 'string', description: 'echoed' } } } };
  async onEcho(p: { msg: string }) { return { msg: p.msg }; }
}

await runtime.create(Echo, 'echo');

// Now any other Matrix participant on COM.NIMBLETEC.RICHARD-SANTOMAURO
// can reach this actor at <root>.echo.$inbox.

Caveats and gotchas

  • Wildcard subscriptions span only your root. subscribe('#', ...) becomes <root>.> — you do not see traffic on other roots unless you publish/subscribe with a cross-root topic.
  • Reply subjects are root-scoped. $replies.cid becomes <root>.$reply.cid. Replies for a request you sent cross-root come back on YOUR root, not the target's.
  • Wire format is JSON, not the legacy stringified envelope. If you have a Python/Go bridge, decode payloads as JSON.
  • disconnect() is the only cleanup. Letting a transport go out of scope without draining leaks the NATS connection until the process exits.

See also

Source: projects/matrix-3/packages/core/src/transport/NatsTransport.ts:33-86 (options), :107-160 (constructor + validation), :267-350 (semanticToNats), :362-422 (natsToSemantic), :235-255 (disconnect); projects/matrix-3/packages/core/src/transport/createTransport.ts (convenience factory).