naps relays messages between NATS servers. It subscribes to subjects (or pulls from a JetStream consumer) on a source server, optionally runs each message through a processor written in TypeScript, JavaScript, Lua or WebAssembly, and publishes the results to a destination server. A processor can rewrite a message, drop it, or fan it out into several messages. naps is configured by one YAML file, exposes health and Prometheus endpoints, and ships as a distroless container image with a Helm chart.
From crates.io:
cargo install naps
naps --config naps.yamlPrebuilt Linux binaries (x86_64 and aarch64) are attached to each GitHub release.
Container image:
docker run --rm -v "$PWD/examples:/etc/naps:ro" ghcr.io/sonirico/naps:latestHelm chart:
helm install naps oci://ghcr.io/sonirico/charts/naps -f values.yamlnaps reads /etc/naps/naps.yaml by default; use --config <path> to change it. Unknown keys
are an error and every referenced file must exist at startup.
http:
listen: "0.0.0.0:8080" # optional, this is the default
source:
url: "nats://nats:4222" # required, also accepts tls://
tls: # optional; ca_file alone = server verification only
ca_file: /etc/naps/tls/ca.crt
cert_file: /etc/naps/tls/tls.crt # cert_file and key_file go together or not at all
key_file: /etc/naps/tls/tls.key
auth: # optional, exactly one key
creds_file: /etc/naps/creds/user.creds
# nkey_file: /path (file holds the nkey seed)
# token_file: /path
destination: # same shape as source
url: "nats://nats:4222"
routes: # at least one
- name: orders # required, unique, [a-z0-9-]+, used as metric label
from: # exactly one of subject | jetstream
subject: "orders.>"
queue_group: naps-orders # optional, defaults to "naps-<name>"
# jetstream: { stream: ORDERS, consumer: naps-orders } # existing durable pull consumer
processor: orders.ts # optional; .ts .js .lua .wasm; relative to the config file dir
to:
mode: core # core | jetstream ; default core
subject: "myapp.orders" # optionalNotes:
fromtakes exactly one ofsubjectorjetstream.queue_grouptogether withjetstreamis a config error.- Output subject precedence: the subject set by the processor, then
to.subject, then the input subject. - A route without a
processorforwards each message unchanged.
See examples/naps.yaml for a complete file with three routes.
A processor exposes one function, recv, that receives a message and returns nothing (drop),
one output, or a list of outputs (fan-out). The message has a subject, headers (a map of
header name to list of values) and data (bytes). An output has an optional subject, optional
headers (empty when missing) and data. Throwing or raising an error is a processing error.
The module exports function recv(msg). msg.data is a Uint8Array; an output data can be
a Uint8Array or a string (UTF-8). Types are stripped from .ts files; import is not
supported. The runtime provides the UTF-8 only globals TextEncoder and TextDecoder.
examples/orders.ts keeps only the confirmed orders and moves them to
myapp.orders.confirmed:
interface Msg {
subject: string;
headers: Record<string, string[]>;
data: Uint8Array;
}
export function recv(msg: Msg) {
const data = new TextDecoder().decode(msg.data);
const order = JSON.parse(data);
if (order.status !== "confirmed") {
return null;
}
return { subject: "myapp.orders.confirmed", data };
}Scripts run in a sandboxed Luau runtime. The global function recv(msg) has the same shape,
with data as a Lua string. It returns nil, an output table, or an array of output tables.
examples/orders.lua does the same without a JSON library:
function recv(msg)
if not string.find(msg.data, '"status":"confirmed"', 1, true) then
return nil
end
return { subject = "myapp.orders.confirmed", data = msg.data }
endA WebAssembly processor is an Extism plugin that exports recv. The
input is the JSON {"subject", "headers", "data"} with data in base64 (standard alphabet,
padded). The output is JSON null, an output object or an array of them, with data in
base64. The plugin has no network, no file system and 64 MiB of memory at most.
A plugin built with extism-pdk (target wasm32-unknown-unknown, crate type cdylib) that
renames the subject from a header:
use std::collections::BTreeMap;
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Msg {
#[serde(skip_serializing_if = "Option::is_none")]
subject: Option<String>,
headers: BTreeMap<String, Vec<String>>,
data: String,
}
#[plugin_fn]
pub fn recv(input: Vec<u8>) -> FnResult<Vec<u8>> {
let mut msg: Msg = serde_json::from_slice(&input)?;
msg.subject = msg.headers.get("x-rename").and_then(|v| v.first()).cloned();
Ok(serde_json::to_vec(&msg)?)
}| Source | Event | Behavior |
|---|---|---|
| core subject | success | at-most-once; outputs are published |
| core subject | processing or publish error | message dropped, logged at warn with route name and error |
| JetStream | every output published (and acked by the server when to.mode: jetstream) |
input is acked |
| JetStream | processing or publish error | input is nacked for redelivery |
| JetStream | zero outputs | input is acked (intentional drop) |
Port 8080 serves /healthz, /readyz and /metrics. All metrics are labelled route.
naps_messages_in_total: every received input.naps_messages_out_total: every published output.naps_messages_dropped_total: inputs for which the processor returned zero outputs.naps_errors_total{stage="process"|"publish"}: errors by stage.naps_process_duration_seconds: processor duration histogram, only for routes with a processor.
The chart is published to oci://ghcr.io/sonirico/charts/naps and lives in charts/naps.
The config value is rendered as naps.yaml; scripts holds processor files by name and
binaryScripts holds .wasm files as base64. Secrets are mounted with secretMounts at the
mountPath of each entry.
just check # fmt, clippy, tests, helm lint and template
just wasm-fixture # rebuild tests/fixtures/scripts/recv.wasm
just image # docker build -t naps:dev .
just e2e # build the image and run the kind end-to-end testThe tests need a running docker daemon.
Releases are driven by release-plz. Every push to main updates a
release PR with the next version and the changelog, derived from conventional commits. Merging
that PR publishes the crate to crates.io, tags vX.Y.Z, creates the GitHub release with Linux
binaries, and pushes the container image and the Helm chart to ghcr.io.
- Thanks to the rust community for such a good documentation and wide range of libraries which have made this journey far easier.
- Thanks to the denoland community for pointing me into the right direction. Specially Andreu Botella, denoland contributor who patiently answered all my questions and guided me to a decent solution. Many thanks, man!