Skip to content

The JMS connector

How to consume messages from a message broker and turn each one into a flow execution: what the connector is, how to configure and start it, what guarantee it gives you, and what it does when things go wrong.

What it is

A separate process. It subscribes to a queue on your broker, and for every message it receives it makes one call to the flow’s queued endpoint:

STOMP POST /enqueue broker queue connector platform queue flow

The server does not know the broker exists. From its side these are ordinary submissions to POST /flows/{tenant}/{flow}/enqueue, so execution, retries, backoff and the dead-letter queue are the platform’s normal queued behaviour — see Queues, retries and the DLQ. The connector’s job stops at getting the bytes accepted.

A flow fed by this connector that declares routes: must also declare serve_by_name: true, or it is refused at publication: its messages arrive through /enqueue, which serves a flow with routes only when it asks. See Served by name.

It talks STOMP 1.2, over plain TCP or over TLS. Any broker that exposes a STOMP endpoint works; “JMS” is the shape of the integration, not the wire protocol.

Configuration

Configuration lives in the front matter of the same .flow.md file you deploy. The platform reads trigger to check that it names a connector and passes over the other trigger keys below; the connector reads them all. max_message_bytes is read by both, so one value sets the platform’s limit and the connector’s. One file describes both halves. The connector reads its keys as the platform reads the front matter, comments included: trigger_queue: orders # the order feed is the queue orders (see Comments).

Key Type Default Meaning
trigger string required Must be jms, in lower case and with nothing after it. A file is skipped, with a line at debug, when none of its keys starts with trigger or, before its first _, is within two letters of it, or when its trigger names another connector; the cases that stop the connector instead are under What stops the connector at start.
trigger_queue string required Broker queue name. Subscribed as /queue/<name>.
trigger_connection string required stomp://host:port, or stomp+ssl://host:port for STOMP over TLS, and nothing else: no user, no password, nothing after the port. Literal text and {{ ctx.KEY }} of a key the flow’s config: declares; see Configuration values.
trigger_login string none The user the connector sends to the broker in CONNECT. Needs trigger_passcode_secret. Literal text and {{ ctx.KEY }} of a key the flow’s config: declares; see Configuration values and Broker login.
trigger_passcode_secret string none The key of the secret that holds the broker passcode, read from NEXUS_SECRET_<KEY>. Needs trigger_login and a stomp+ssl:// connection.
trigger_tls_ca_cert path none A PEM file of the certificates that sign the broker’s certificate. Without it, the public roots are trusted. Needs a stomp+ssl:// connection.
trigger_content_type string application/json The Content-Type a message is submitted with when its frame has no STOMP content-type header, such as application/xml. Checked as a media type when the connector starts; nothing is substituted. See From broker message to submission.
trigger_concurrency integer 1 Number of workers for this flow. Each opens its own connection and its own subscription. At least 1.
max_message_bytes integer 1048576 Largest message body the connector will forward. Must be greater than zero. trigger_max_body_bytes is a deprecated alias.

flow: and tenant: are read too — they are what the submission URL is built from.

One pair of quotes around a value, "…" or '…', is stripped, the same way the platform strips it.

What stops the connector at start

The connector reads its flow files once, when it starts, and stops before it consumes anything at the first key it cannot read as written. The error names the file’s key:

  • A key that starts with trigger, or that is within two letters of it (Trigger_queue, triger_queue), and is not one of the keys above: “trigger_queu is not a key of the jms connector; its keys are trigger, …”, followed by “did you mean trigger_queue?” when one is that close. The value is not repeated.
  • Such keys without a trigger: key: “the file has Trigger, trigger_queue but no trigger: key”.
  • trigger empty, in another case (JMS), with a space or tab and more after jms (jms extra), or quoted with blanks around it (" jms"): “trigger is empty”, or “trigger must be exactly jms, in lower case, with nothing after it”. The first word of trigger ends at the first space or tab; a first word other than jms in any case, jms2 included, names another connector, and the file is skipped with a line at debug.
  • A value that is not of its kind. A number must be a whole number at or above its minimum: trigger_concurrency: 0 gives “trigger_concurrency is “0”: it must be an integer, at least 1”. Nothing is rounded up to the minimum, there is no maximum, and a text key must not be empty.
  • A trigger_connection that is not stomp://host:port or stomp+ssl://host:port, after its {{ ctx.KEY }} values are read. The error names the key and never the address:
Address Error
tcp://broker:61613 trigger_connection must start with stomp:// or stomp+ssl://
stomp://user:pass@broker:61613 trigger_connection has a user and password: the user goes in trigger_login and the passcode in trigger_passcode_secret
stomp://broker, stomp://broker:0 trigger_connection needs a port from 1 to 65535
stomp://broker:61613/queue trigger_connection has something after the port
stomp://bad host:61613 trigger_connection needs a host of letters, digits, ., - and _, or an IPv6 address in brackets
  • A trigger_content_type that is not a media type, xml or application/ included: “trigger_content_type is not a media type such as application/xml or text/plain; charset=utf-8”. The value is not repeated.
  • A broker login or a CA file that cannot be used. The error names the key and the variable, never the user, the passcode or a byte of the file:
Case Error
trigger_login without trigger_passcode_secret trigger_login is set without trigger_passcode_secret: a broker login needs both
trigger_passcode_secret without trigger_login trigger_passcode_secret is set without trigger_login: a broker login needs both
trigger_login holding a line break or a NUL trigger_login holds a line break or a NUL, which a STOMP CONNECT frame cannot carry
trigger_passcode_secret: my key trigger_passcode_secret must be a secret key of letters, digits, _, . and -
NEXUS_SECRET_<KEY> not set trigger_passcode_secret: environment variable NEXUS_SECRET_<KEY> is not set
NEXUS_SECRET_<KEY> set to an empty value trigger_passcode_secret: environment variable NEXUS_SECRET_<KEY> is set but empty
NEXUS_SECRET_<KEY> holding a line break or a NUL trigger_passcode_secret: environment variable NEXUS_SECRET_<KEY> holds a line break or a NUL, which a STOMP CONNECT frame cannot carry
NEXUS_SECRET_<KEY> that is not UTF-8 trigger_passcode_secret: environment variable NEXUS_SECRET_<KEY> is not UTF-8, which a STOMP CONNECT frame cannot carry
trigger_passcode_secret with a stomp:// connection trigger_passcode_secret needs a stomp+ssl:// connection: on stomp:// the passcode would cross the network in clear
trigger_tls_ca_cert with a stomp:// connection trigger_tls_ca_cert needs a stomp+ssl:// connection
trigger_tls_ca_cert naming a file that cannot be read `trigger_tls_ca_cert`: not readable, or not a regular file, or over the size limit
trigger_tls_ca_cert naming a file that is not PEM trigger_tls_ca_cert: not a PEM file of certificates
trigger_tls_ca_cert naming a certificate that cannot be a trust anchor trigger_tls_ca_cert: a certificate in it cannot be a trust anchor
trigger_tls_ca_cert naming a PEM file with no certificate trigger_tls_ca_cert holds no certificate

One file stopped stops the whole process, with every flow it serves, other tenants’ included. nexus validate and nexus deploy refuse a flow whose trigger is not exactly jms or vfs: trigger: jsm gives “trigger names no connector: it must be one of jms, vfs; did you mean jms?”, and the hint comes only when a name is within two letters of the value. A typo in the name of another trigger key passes nexus deploy and shows at the connector’s next restart. Restart it after changing a trigger key, and read its first lines.

Configuration values

trigger_connection and trigger_login hold literal text and {{ ctx.KEY }} of a key the flow declares in config:. The connector reads each such key from NEXUS_CONFIG_<KEY> when it starts, once, and puts the value in its place:

config: [ORDERS_BROKER]
trigger_connection: "stomp://{{ ctx.ORDERS_BROKER }}:61613"

nexus validate and nexus deploy refuse any other {{ }} in the key, and a key config: does not declare, naming the key:

Written Refusal
{{ $.host }} trigger_connection: only {{ ctx.<KEY> }} of a key declared in config: may be written in a connector key
{{ ctx.BROKER }} without BROKER in config: trigger_connection: {{ ctx.BROKER }} names a key that config: does not declare

An environment variable written directly in the key is refused at publication too; see What a flow may read.

Three cases stop the connector before it consumes anything. The error names the key and the variable, never a value:

Case Error
The variable is not set `trigger_connection` cannot be resolved: NEXUS_CONFIG_ORDERS_BROKER is not set
The variable is set and empty `trigger_connection` cannot be resolved: NEXUS_CONFIG_ORDERS_BROKER is empty
The value holds {{ `trigger_connection` cannot be resolved: NEXUS_CONFIG_ORDERS_BROKER holds {{, which cannot be substituted

A half-built broker address would otherwise produce a connector that runs and quietly consumes nothing. A value read from the variable is not read again. nexus serve checks the same variable at its own start, as for every config: key; see How configuration is named.

An address with a user and password, stomp://user:pass@host:port, is not a login: it stops the connector at start, as above. The login has keys of its own.

Broker login

A broker that asks for a user and a passcode gets them in the STOMP CONNECT frame, as the login and passcode headers:

trigger_connection: stomp+ssl://broker.example.com:61614
trigger_tls_ca_cert: /etc/nexus/broker-ca.pem
trigger_login: orders-app
trigger_passcode_secret: orders-broker
  • The passcode is never written in the flow file. trigger_passcode_secret names a secret, as the key: of a secret_read step does, and the connector reads its value from NEXUS_SECRET_<KEY>: the key upper-cased, with - and . turned into _. orders-broker is read from NEXUS_SECRET_ORDERS_BROKER. Nothing is substituted in this key.
  • The passcode travels only over TLS. A passcode on a stomp:// connection stops the connector at start, whatever the host, localhost included. Enable STOMP over TLS on the broker (ActiveMQ’s stomp+ssl transport connector, RabbitMQ’s STOMP TLS listener, an Artemis acceptor with sslEnabled) and point trigger_connection at that port.
  • The passcode is read once, at start. To change it, set the new value and restart the connector. It appears in no log line and no error.
  • trigger_login and trigger_passcode_secret go together. A stomp:// or stomp+ssl:// connection without either connects as before, with no login.

stomp+ssl:// opens TLS 1.3 or 1.2 before CONNECT and checks the broker’s certificate against the host written in trigger_connection; an IP address is checked against the certificate’s IP addresses. Without trigger_tls_ca_cert, the public roots are trusted, the same set the connector trusts for the platform. With it, only the certificates of that file are, so a broker whose certificate a private CA signs needs it. The file is read at start, where a bad file stops the connector, and again at every connect, so a replaced CA file takes effect at the next connect. A certificate that does not verify fails the session, which reconnects after its wait; nothing is sent to the broker, and no setting turns the check off. The connector presents no client certificate.

When the broker answers CONNECT with an ERROR frame, a wrong user or passcode for instance, the worker stops instead of reconnecting, with one line at error: “worker stopped — the broker refused the STOMP CONNECT; nothing was taken from the queue; check trigger_login, trigger_passcode_secret and the broker’s own log, then restart the connector”. The line names the worker, the tenant, the flow and whether a login was sent (login_sent), and nothing the broker said; the broker’s own log says why it refused. Nothing was taken off the queue. The other workers and flows of the connector keep running.

The connector reads the environment it was given. Unlike nexus, it does not load a .env file.

A key written more than once is read in file order: every occurrence must be valid, so a malformed one stops the connector, naming the key, whichever side of a valid one it is on; when all are valid, the last stands.

A configured flow

---
flowmarkdown_version: "0.1"
flow: order-intake
tenant: acme
trigger: jms
trigger_queue: ORDERS_IN
config: [ORDERS_BROKER]
trigger_connection: "stomp://{{ ctx.ORDERS_BROKER }}:61613"
trigger_concurrency: 2
max_message_bytes: 1048576
queue_max_attempts: 5
queue_initial_delay_ms: 1000
effects: [http_egress]
---
## Step: normalise
```ntd
{
"orderId": "{{ $.orderId }}",
"total": {{ $.total }},
"receivedAt": "{{ date_format(now()) }}"
}
```
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
content_type: application/json

Starting it

The binary is nexus-connector, delivered built alongside the platform. Give it one or more flow files:

Terminal window
$ nexus-connector --server http://localhost:9090 \
--store /var/lib/nexus/jms.db \
order-intake.flow.md invoice-intake.flow.md
Flag Default Meaning
--server <url> http://localhost:9090 Base URL of the server. Also read from NEXUS_SERVER_URL.
--store <path> nexus-jms-connector.db Local database used for crash recovery. Created if absent.
--api-key <key> none Sent as Authorization: Bearer <key> on every request the connector makes. Also read from NEXUS_API_KEY.
<flows...> required One or more .flow.md paths. At least one must carry a JMS trigger.

At startup, in this order: anything left in the local store from a previous run is submitted first; then each file is read. Files are skipped or refused as described under What stops the connector at start. A file that cannot be read is fatal, and so is one the platform cannot parse when one of its trigger keys names jms, in any case: the connector exits naming the file, rather than starting with one queue silently unconsumed. A file with a JMS trigger is read with the platform’s own parser. The connector refuses to start on a file the platform cannot parse when one of its trigger keys names jms, and on a file whose tenant or flow breaks the rule for a name that becomes a path. A file the platform parses but nexus validate refuses for another reason does not stop it. If no file carries a JMS trigger, it exits.

Rows an earlier release stored under a tenant or flow that breaks that rule are the exception to the first step: they are not submitted, and every start logs one ERROR per pair, whose remedy says to set the names the flow file declares on those rows of pending_messages in the local store, which sends them at the next start, or to delete the rows to drop the messages.

If the connector cannot read what the local store holds in flight, it logs an ERROR, submits none of it, and starts. The rows stay in the store and are submitted at the next start that can read them. Do not delete the store: those rows are the only copy of messages already taken off the broker.

Then it runs until Ctrl+C. Operational output goes to stderr, never to stdout, and RUST_LOG sets its level. Colour codes are added only when stderr is a terminal and NO_COLOR is unset or empty, so a redirected log stays plain text. A RUST_LOG that is not valid Unicode is read as unset. When RUST_LOG is set, it is used as written, so RUST_LOG=debug also shows each file skipped; a part of it that cannot be read is left out and named on stderr, and when no part can be read only error lines show. When it is not set, or is empty, it depends on the target, the name after the level on each line: nexus_connector shows from info up, every other target only at error. nexus_connector is on the lines that announce the replay of the local store, the workers started and the stop, such as starting connector workers and shutting down. The workers’ lines and the retries of a submission, at start and while running, are under nexus_jms_connector::worker, so without RUST_LOG connected to broker, reconnecting and the retry lines stay hidden. The two warnings of a /connector/events report the server refuses or that cannot be sent are under nexus_connector and show without RUST_LOG. A RUST_LOG that names only other targets, such as RUST_LOG=reqwest=debug, hides every line of the connector, its error lines included; RUST_LOG=error,reqwest=debug keeps them. A connector with messages in its local store and the server down shows replaying <n> pending message(s) from local store, then no line while it retries.

From broker message to submission

What the broker sends What the connector does with it
The message body Becomes the request body of POST /flows/{tenant}/{flow}/enqueue, byte for byte.
The message-id header Becomes the message’s identity for deduplication, and is recorded in the log.
The ack header Used to acknowledge that one message.
The content-type header Becomes the submission’s Content-Type, as written, parameters included.
Everything else Not forwarded. Other broker headers and JMS properties do not reach the flow.

The submission’s Content-Type is, in this order:

  1. the message’s STOMP content-type header, the first one when the frame repeats it, its name in lower case exactly and its value unescaped as STOMP 1.2 defines (\c is :);
  2. otherwise the flow’s trigger_content_type;
  3. otherwise application/json.

The type is chosen once, when the message is taken off the broker, and kept with it in the local store: a message submitted again at the next start (see Delivery guarantee below) keeps its type. A message stored by an earlier connector, which kept no type, is submitted as application/json, as that connector submitted it.

The value is sent as it is. The server decides what it accepts and how it reads it, and the flow reads the result as $: see /enqueue for the types it reads. A body the server cannot read as its type is answered with 400: the connector quarantines that message, body included, and moves on to the next one (see Permanent refusals below). Nothing is retried with another type.

A message whose content-type header is not a media type, such as xml, is not submitted. It is quarantined in the local store, body included, as a 400 would be, before it is acknowledged to the broker, so no restart submits it, and the worker moves on to the next one. The line message quarantined — its STOMP `content-type` header is not a media type, at warn under nexus_jms_connector::worker, names the flow and the tenant, never the header’s value or the body; without RUST_LOG it stays hidden.

The connector and the server read the same max_message_bytes out of the same front matter, so the two ceilings agree by construction — raise it in the file and both halves move together. What they do not share is the installation-wide default underneath it: a flow that declares nothing gets the server’s max_message_bytes from system_config, and if an operator has lowered that below 1 MiB, the connector will forward messages the server then refuses with 413. See Limits.

Ordering: one worker consumes and submits one message at a time, in the order the broker delivers them. With trigger_concurrency above 1 there are that many independent consumers and nothing preserves order between them.

Delivery guarantee

At-least-once. A message that reaches the connector is submitted at least once; it can be submitted twice. Make the flow tolerate a repeated message.

The mechanism, per message, in this order:

  1. Write the message to the local store.
  2. Report connector_received.
  3. ACK the broker — the message leaves the queue.
  4. POST it to /enqueue, retrying every failure until a 2xx: 1 second, doubling to a 60-second ceiling, forever.
  5. Mark it delivered in the local store.

The store is what makes step 3 safe: once the bytes are on local disk the broker’s copy is no longer needed, and the submission can take as long as it takes. Steps 4 and 5 do not depend on the broker connection — a drop mid-retry changes nothing.

At the next start, everything still marked in flight is submitted again, with the Content-Type chosen when it was taken off the broker, and then marked delivered, before any worker connects. A broker that redelivers a message-id already marked delivered gets an ACK, and the message is dropped rather than submitted twice.

Three things produce a duplicate:

  • A crash between the server accepting a submission and step 5. The message is still marked in flight, so it is submitted again at the next start.
  • A broker that does not set message-id. The connector assigns a fresh identity to each delivery, so nothing matches on redelivery and the dedupe check cannot fire. Most brokers set it.
  • A delivery the local store cannot record. The server accepted the message, but the store could not mark it delivered (a full disk, a read-only file). It stays in flight and is submitted again at the next start; an ERROR names the message and says so. The connector then stops taking messages off the broker and reconnects, and a start that meets the same failure stops replaying there and leaves the rest for the next start. While the store stays unwritable this costs one duplicate per reconnect, not one per message.

Records of delivered messages older than 24 hours are discarded when the connector starts. Deleting the store file discards them all, and discards anything still in flight with them.

Oversized messages

A body larger than max_message_bytes is discarded as it is read. The message is ACKed — it is gone from the broker — and never forwarded. A connector_oversized event is reported.

This is a drop, not a dead-letter: after the ACK there is no copy anywhere. Set the limit deliberately, and if losing a message is unacceptable, make the producer refuse to publish one that large.

The size field on that event is reported as zero, because the body is thrown away as it is read rather than buffered.

Note the asymmetry: a message stopped here, by the connector’s own limit, is dropped and no copy survives. One the platform refuses with 413 is kept — see below.

Permanent refusals

The connector retries a rejected submission — but only where retrying can work. Network failures, 429 and 5xx are retried; 400, 401, 403 and 413 are final answers and end the attempt.

Status What it means What happens to the message
401, 403 The key is wrong, revoked, expired, or not allowed on this flow or tenant The message stays in the connector’s pending store and the worker stops. Fix the key, restart, and replay_pending submits it.
400 The platform cannot read the message as the content type it was submitted with, or the document is nested too deeply to be queued Quarantined in the local store, body included, and the worker carries on
413 The message is over the platform’s ceiling for this flow Quarantined in the local store, body included, and the worker carries on
429 Rate limited Waits for Retry-After when the server sends one (seconds form, capped at 5 minutes), otherwise backs off, then retries
5xx, network The platform is down or restarting Backs off and retries

The distinction is what is wrong. On 401/403 nothing is wrong with the message, so it is kept untouched; a worker that carried on would burn the rest of the queue against the same refusal. On 400 and 413 the message is the problem, so it is set aside and work continues. That 401 really does mean “wrong credential” is guaranteed by the platform: the gate answers 503, never 401, when it cannot decide.

Every permanent refusal is logged at error with the status, tenant, flow and what to do about it. Quarantined messages are not evicted by the 24-hour cleanup — they hold the only surviving copy of a message that has already been acknowledged to the broker, so inspect the store after one of these.

404 is still retried indefinitely, on purpose: a flow that is not deployed yet, or is disabled, and a suspended tenant all answer it, and each can pass without anyone touching the message. A flow withdrawn with nexus undeploy answers 404 too, so the connector keeps retrying and the message stays at its source until the flow is deployed again or the connector is stopped.

When the broker connection drops

The session ends and the worker reconnects. It waits 2 seconds, then 4, 8, 16, 32, and 60 thereafter. A session that stayed up at least 30 seconds before failing resets the wait to 2 seconds, so an occasional drop does not push a healthy connector into minute-long gaps. It keeps trying, except after an ERROR in answer to CONNECT.

A broker that is unreachable at startup behaves the same way — the worker retries until it answers. So does a port on which no broker listens, a connection the broker closes before it answers, and a TLS handshake that fails. An address that is not stomp://host:port or stomp+ssl://host:port never gets this far: it stops the connector at start. The one answer that stops the worker is an ERROR frame in answer to CONNECT; see Broker login.

Events it reports

The connector calls POST /connector/events/{tenant}/{flow} so that a message picked up from a broker appears in the same structured log as an HTTP request. The call is fire-and-forget: a failure is logged and message processing continues.

Event When Carries
connector_received The message is on local disk, not yet submitted Correlation ID, message ID, worker number
connector_oversized A message exceeded the size limit and was dropped Message ID, worker number

The connector generates one correlation ID per message, reports it on connector_received, and sends it as X-Correlation-ID on the submission. The connector_received and queue_enqueued entries therefore carry the same ID, and so does everything the flow logs when the worker picks the message up. See Logging and audit.

A complete setup

Register the tenant, deploy the flow, and issue the key the connector will present:

Terminal window
$ nexus tenant create --id acme --display-name "Acme Ltd"
$ nexus deploy order-intake.flow.md --version 1.0.0
$ nexus keys create --tenant acme --label jms-connector \
--scopes '*' --flows order-intake
created API key id=1
key: nxk_4f19c0b27ad3489e93f5c1e6a70d2b84
$ export NEXUS_CONFIG_ORDERS_BROKER=broker.example.com
$ nexus serve --port 9090

Give the connector its own key, scoped to the one flow it submits to with --flows. It authenticates like any other caller — there is no connector-shaped exemption — so a shared key would mean revoking it takes the other callers down too.

Point the connector at both the broker and the server:

Terminal window
$ export NEXUS_CONFIG_ORDERS_BROKER=broker.example.com
$ export NEXUS_API_KEY=nxk_4f19c0b27ad3489e93f5c1e6a70d2b84
$ nexus-connector --server http://localhost:9090 \
--store /var/lib/nexus/jms.db \
order-intake.flow.md

Without a key the connector gets 401 on its first submission and stops, leaving the message recoverable on the broker: a credential refusal is terminal, not something to retry forever.

Publish a JSON message to ORDERS_IN, and it arrives as the flow’s input:

{ "orderId": "A-1", "total": 42 }

The flow runs on the platform’s queue worker, so its result is not returned anywhere — whatever the integration is for has to happen in an effect. Follow the message by its correlation ID in the log, or watch the Queue page of the management UI.

To change the flow’s steps, deploy a new version; the queue worker resolves the newest artifact when it picks a message up, and the connector does not need restarting. To change any trigger_* key, restart the connector — it reads the file once, at startup.

Not supported

A client certificate for the broker: the login is a user and a passcode. Topics — the subscription is always a queue. Broker headers and message properties are not passed to the flow. The connector does not sign its submissions, so a server started with NEXUS_WEBHOOK_SECRET set rejects them.

A push subscription to a third-party product: the connector consumes a broker queue and nothing else, and nothing in the platform registers with another product to be notified. A source that has to announce itself calls the flow’s endpoint directly and needs no connector; see the HTTP API reference.