Speaking MassTransit from Node over RabbitMQ
Getting a Node/TS service to interoperate with a .NET app that uses MassTransit, keeping the .NET side an
ordinary typed Publish/IConsumer<T> (no polyglot special-casing).
Do not use masstransit-rabbitmq (0.1.x); hand-roll with raw amqplib
The masstransit-rabbitmq npm package (0.1.x observed) is unfit for RabbitMQ 4.x and for real interop, for four
independent reasons found live:
- No credentials. Its
HostSettings has only host/virtualHost/port/ssl/heartbeat — no
username/password — so it dials as guest and RabbitMQ refuses (403 ACCESS_REFUSED, PLAIN).
- frame_max too low (see below) — the connection is reset even with valid creds.
- Deprecated features — it uses
globalPrefetch: true (global QoS) and declares transient non-exclusive
queues, both denied by RabbitMQ 4.x (see below).
- It never binds the message-type exchanges. Its receive endpoint only creates
fanout exchange = queueName
queue = queueName and binds those; a message .NET publishes to Namespace:TypeName never routes to it.
Raw amqplib emitting/parsing the envelope yourself is simpler and correct. Put it behind a small interface so
the transport is a swappable provider (the shape MassTransit itself gives .NET).
The MassTransit wire contract (RabbitMQ)
Exchange per message type: "<Namespace>:<TypeName>", e.g. MyApp.Contracts:OrderSubmitted. Declare it
fanout, durable. MassTransit's consumer receive endpoints bind these exchanges to their queues — you must
replicate that binding.
Envelope (JSON body): the essentials are messageId, messageType (a URN array), message (the payload),
sentTime. Content type application/vnd.masstransit+json. sourceAddress/destinationAddress/host are
optional for consume.
{ "messageId": "<guid>", "messageType": ["urn:message:MyApp.Contracts:OrderSubmitted"],
"message": { ...payload... }, "sentTime": "2026-...Z" }
Consume what .NET published: assert a durable queue, assert the message-type exchange (fanout durable), bind
queue -> exchange, consume, parse envelope.message.
Publish to a .NET IConsumer<T>: publish the envelope to that message-type exchange; .NET's receive-endpoint
queue is already bound to it.
Casing: MassTransit deserializes the payload case-insensitively, so publishing PascalCase (matching the .NET
record) is safe; when consuming, read fields case-insensitively (accept Id or id) to be robust. The
message-type URN in the envelope, not the payload, is how you tell which type it is.
RabbitMQ 4.x incompatibilities (and the fixes)
RabbitMQ 4.x tightened defaults; a 3.x-era client trips several. All confirmed via the broker log.
- frame_max floor 8192. amqplib defaults
frameMax to 4096, below the 4.x minimum, so the broker resets the
connection after auth: broker log failed to negotiate connection parameters: negotiated frame_max = 4096 is lower than the minimum allowed value (8192); the client just sees ECONNRESET. Set it: amqp.connect({ ..., frameMax: 131072 }) or ...?frameMax=131072 in the URL.
- Deprecated features denied by default, each killing the connection with
541 INTERNAL_ERROR on the
offending op:
global_qos — from channel.prefetch(count, /*global*/ true). Use per-consumer QoS: channel.prefetch(1)
(global defaults false).
transient_nonexcl_queues — a non-durable, non-exclusive queue. Make queues durable, or make a
per-instance queue exclusive (transient exclusive is permitted). Both dodge it.
- Broker-side alternative (if you cannot change the client):
deprecated_features.permit.<name> = true in a
rabbitmq.conf under /etc/rabbitmq/conf.d/, then restart the broker. Note: a runtime rabbitmqctl eval 'application:set_env(rabbit, permit_deprecated_features, ...)' did not take effect for the queue.declare
in testing — it is effectively config/boot-time.
- guest is localhost-only (as in 3.x) — pass real credentials for any non-localhost connection.
Diagnosis: read the RabbitMQ broker log, not just the client error
The client sees vague errors (ECONNRESET, 541); the broker log names the exact cause. docker logs <rabbit>
shows lines like PLAIN login refused: user 'guest', failed to negotiate ... frame_max, and operation queue.declare caused a connection exception internal_error: Feature transient_nonexcl_queues is deprecated.
Useful rabbitmqctl: authenticate_user <u> <p> (is the password right?), list_connections user peer_host state, list_queues name messages consumers (is anyone consuming?).
Validate the round trip against a standalone rabbitmq:4-management container (own docker network): publish a
MassTransit envelope, confirm your worker consumes and decodes it, publish a report envelope, and read it back —
before wiring into the real app.
CJS/ESM import trap (masstransit-rabbitmq and similar CJS libs)
If you do use a CJS package from an ESM ("type": "module") project under Node16 resolution, watch for
double-wrapping: a CJS module with exports.default = fn and __esModule imported into ESM gives the module
object as the default, with the real callable one level in at .default. Probe the runtime shape rather than
trust the .d.ts:
node --input-type=module -e "import * as ns from 'pkg'; console.log(typeof ns.default, typeof ns.default?.default)"
Named exports may live only on submodules (pkg/dist/foo.js), not the package root. And if you must monkey-patch
a transitive dep (e.g. patch amqplib.connect) so the library uses your patched copy, add an npm overrides for
that dep to force a single deduped instance — otherwise the library nests its own copy and your patch misses it.
1---2name: masstransit-nodejs-interop3description: Interoperating between a Node/TypeScript service and a .NET MassTransit app over RabbitMQ, with no .NET runtime on the Node side. Use when a non-.NET worker must consume or publish MassTransit messages, when weighing or rejecting the masstransit-rabbitmq npm library, when RabbitMQ 4.x rejects a client (403 as guest, a frame_max negotiation reset / ECONNRESET, 541 INTERNAL_ERROR from transient_nonexcl_queues or global_qos being deprecated), or when a message published to a message-type exchange never reaches a MassTransit consumer. Covers the MassTransit JSON envelope + exchange topology to hand-roll with raw amqplib, why masstransit-rabbitmq 0.1.x is unfit for RabbitMQ 4.x, the RabbitMQ 4.x incompatibilities and their fixes, broker-log diagnosis, and the CJS-double-wrap ESM import trap.4---56# Speaking MassTransit from Node over RabbitMQ78Getting a Node/TS service to interoperate with a .NET app that uses MassTransit, keeping the .NET side an9ordinary typed `Publish`/`IConsumer<T>` (no polyglot special-casing).1011## Do not use `masstransit-rabbitmq` (0.1.x); hand-roll with raw `amqplib`1213The `masstransit-rabbitmq` npm package (0.1.x observed) is unfit for RabbitMQ 4.x and for real interop, for four14independent reasons found live:15161. **No credentials.** Its `HostSettings` has only `host`/`virtualHost`/`port`/`ssl`/`heartbeat` — no17 username/password — so it dials as `guest` and RabbitMQ refuses (`403 ACCESS_REFUSED`, PLAIN).182. **frame_max too low** (see below) — the connection is reset even with valid creds.193. **Deprecated features** — it uses `globalPrefetch: true` (global QoS) and declares transient non-exclusive20 queues, both denied by RabbitMQ 4.x (see below).214. **It never binds the message-type exchanges.** Its receive endpoint only creates `fanout exchange = queueName`22 + `queue = queueName` and binds those; a message .NET publishes to `Namespace:TypeName` never routes to it.2324Raw `amqplib` emitting/parsing the envelope yourself is simpler and correct. Put it behind a small interface so25the transport is a swappable provider (the shape MassTransit itself gives .NET).2627## The MassTransit wire contract (RabbitMQ)2829- **Exchange per message type:** `"<Namespace>:<TypeName>"`, e.g. `MyApp.Contracts:OrderSubmitted`. Declare it30 **fanout, durable**. MassTransit's consumer receive endpoints bind these exchanges to their queues — you must31 replicate that binding.32- **Envelope** (JSON body): the essentials are `messageId`, `messageType` (a URN array), `message` (the payload),33 `sentTime`. Content type `application/vnd.masstransit+json`. `sourceAddress`/`destinationAddress`/`host` are34 optional for consume.3536 { "messageId": "<guid>", "messageType": ["urn:message:MyApp.Contracts:OrderSubmitted"],37 "message": { ...payload... }, "sentTime": "2026-...Z" }3839- **Consume what .NET published:** assert a durable queue, assert the message-type exchange (fanout durable), bind40 queue -> exchange, consume, parse `envelope.message`.41- **Publish to a .NET `IConsumer<T>`:** publish the envelope to that message-type exchange; .NET's receive-endpoint42 queue is already bound to it.43- **Casing:** MassTransit deserializes the payload case-insensitively, so publishing PascalCase (matching the .NET44 record) is safe; when *consuming*, read fields case-insensitively (accept `Id` or `id`) to be robust. The45 message-type URN in the envelope, not the payload, is how you tell which type it is.4647## RabbitMQ 4.x incompatibilities (and the fixes)4849RabbitMQ 4.x tightened defaults; a 3.x-era client trips several. All confirmed via the broker log.5051- **frame_max floor 8192.** amqplib defaults `frameMax` to 4096, below the 4.x minimum, so the broker resets the52 connection after auth: broker log `failed to negotiate connection parameters: negotiated frame_max = 4096 is53 lower than the minimum allowed value (8192)`; the client just sees `ECONNRESET`. Set it: `amqp.connect({ ...,54 frameMax: 131072 })` or `...?frameMax=131072` in the URL.55- **Deprecated features denied by default**, each killing the connection with `541 INTERNAL_ERROR` on the56 offending op:57 - `global_qos` — from `channel.prefetch(count, /*global*/ true)`. Use per-consumer QoS: `channel.prefetch(1)`58 (global defaults false).59 - `transient_nonexcl_queues` — a non-durable, non-exclusive queue. Make queues **durable**, or make a60 per-instance queue **exclusive** (transient *exclusive* is permitted). Both dodge it.61 - Broker-side alternative (if you cannot change the client): `deprecated_features.permit.<name> = true` in a62 `rabbitmq.conf` under `/etc/rabbitmq/conf.d/`, then restart the broker. Note: a runtime `rabbitmqctl eval63 'application:set_env(rabbit, permit_deprecated_features, ...)'` did **not** take effect for the queue.declare64 in testing — it is effectively config/boot-time.65- **guest is localhost-only** (as in 3.x) — pass real credentials for any non-localhost connection.6667## Diagnosis: read the RabbitMQ broker log, not just the client error6869The client sees vague errors (`ECONNRESET`, `541`); the broker log names the exact cause. `docker logs <rabbit>`70shows lines like `PLAIN login refused: user 'guest'`, `failed to negotiate ... frame_max`, and `operation71queue.declare caused a connection exception internal_error: Feature transient_nonexcl_queues is deprecated`.72Useful `rabbitmqctl`: `authenticate_user <u> <p>` (is the password right?), `list_connections user peer_host73state`, `list_queues name messages consumers` (is anyone consuming?).7475**Validate the round trip** against a standalone `rabbitmq:4-management` container (own docker network): publish a76MassTransit envelope, confirm your worker consumes and decodes it, publish a report envelope, and read it back —77before wiring into the real app.7879## CJS/ESM import trap (masstransit-rabbitmq and similar CJS libs)8081If you do use a CJS package from an ESM (`"type": "module"`) project under Node16 resolution, watch for82double-wrapping: a CJS module with `exports.default = fn` and `__esModule` imported into ESM gives the *module83object* as the default, with the real callable one level in at `.default`. Probe the runtime shape rather than84trust the `.d.ts`:8586 node --input-type=module -e "import * as ns from 'pkg'; console.log(typeof ns.default, typeof ns.default?.default)"8788Named exports may live only on submodules (`pkg/dist/foo.js`), not the package root. And if you must monkey-patch89a transitive dep (e.g. patch `amqplib.connect`) so the library uses your patched copy, add an npm `overrides` for90that dep to force a single deduped instance — otherwise the library nests its own copy and your patch misses it.