@crvouga/mockingbird-service-emqx

中文文档
0.10.0发布于 2026/11/11MITTypeScriptES Module构建来源已验证近 30 天下载 2962

Stateful emulator of the EMQX 5 MQTT broker: MQTT 5 over TCP and WebSocket, password and JWT authentication with JWT ACLs, persistent sessions with offline QoS 1 queues, and the REST v5 publish and client-kick endpoints.

原始使用文档
文档目录9 节
  1. Install
  2. Usage
  3. MQTT
  4. Routes
  5. Admin (beyond the standard contract)
  6. Namespaces
  7. Unverified
  8. Deliberately not modelled
  9. API

@crvouga/mockingbird-service-emqx

Local emulators. Real API contracts. Part of Mockingbird.

Stateful emulator of an EMQX 5 MQTT broker for test suites and local development: an MQTT 5 broker over TCP and WebSocket (/mqtt), EMQX's password and JWT authentication with JWT ACLs, persistent sessions with offline QoS 1 queues, and the two REST v5 endpoints a publish-only client needs (POST /api/v5/publish, DELETE /api/v5/clients/{clientid}). It runs in the test process on OS-assigned ports, with no installed broker, Docker, or native binary, and session expiry follows a clock the suite moves instead of waiting.

Install

npm install -D @crvouga/mockingbird-service-emqx

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-emqx serve or createServer from ./server (Node and Bun). createRuntime (the default entry) is portable: it serves the REST API over fetch and MQTT through runtime.attach, with no listener.

Usage

Point the app's MQTT URL at mqttUrl (or wsUrl), its EMQX API origin at url, and give it the API key, MQTT user and JWT secret you configured here.

import { createServer } from "@crvouga/mockingbird-service-emqx/server"
import mqtt from "mqtt"

const emqx = await createServer({
  settings: {
    apiKeys: [{ key: "test-key", secret: "test-secret" }],
    users: [{ username: "service", password: "service-password", superuser: true }],
    jwt: { secret: "test-jwt-secret" }, // HS256 token in the CONNECT password
    authorization: { noMatch: "deny" },
  },
})
// emqx.url      http://127.0.0.1:<port>       REST API and /__admin
// emqx.mqttUrl  mqtt://127.0.0.1:<port>       MQTT 5 over TCP
// emqx.wsUrl    ws://127.0.0.1:<port>/mqtt    MQTT 5 over WebSocket

const client = await mqtt.connectAsync(emqx.mqttUrl, {
  protocolVersion: 5,
  clientId: "device-1",
  clean: false,
  properties: { sessionExpiryInterval: 600 },
  username: "service",
  password: "service-password",
})
await client.subscribeAsync("space:example", { qos: 1 })

// EMQX publishes no JavaScript SDK for its REST API, so the publisher calls it over HTTP.
await fetch(`${emqx.url}/api/v5/publish`, {
  method: "POST",
  headers: {
    authorization: `Basic ${btoa("test-key:test-secret")}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({ topic: "space:example", payload: "hello", qos: 1, retain: false }),
})

// Test control: move time instead of waiting for a session to expire.
await fetch(`${emqx.url}/__admin/clock`, { method: "POST", body: '{"advance": "600s"}' })

await client.endAsync()
await emqx.close() // closes every listener and drops every client
npx mockingbird-emqx serve --port 8850 --mqtt-port 1883 \
  --api-key test-key:test-secret --mqtt-user service:service-password \
  --jwt-secret test-jwt-secret --no-match deny

Each createServer() is its own broker with its own ports; two of them share nothing. Under Bun, mqtt's Node WebSocket transport does not start (Bun's ws has no createWebSocketStream), so connect to wsUrl with the library's browser transport (new MqttClient((c) => browserStreamBuilder(c, options), options) from mqtt/lib/connect/ws), or use connectStream(runtime) from ./server for a socket-free connection. On Node the plain mqtt.connect("ws://…/mqtt") works.

MQTT

MQTT 5.0 over TCP and over WebSocket at /mqtt (subprotocol mqtt, also mqtt-v3, mqtt-v3.1.1, mqtt-v5; an upgrade without one is answered 400, as EMQX does with fail_if_no_subprotocol). Both transports reach the same sessions.

PacketBehaviour
CONNECT / CONNACKClean Start and Session Expiry Interval as in the specification: clean: false resumes a stored session (sessionPresent: true), a clean start discards it. EMQX takes the client's interval as sent. The CONNACK carries Maximum Packet Size 1048576, Topic Alias Maximum 65535, Receive Maximum 32, Wildcard Subscription Available 1, Subscription Identifier Available 1, and what this emulator does not do: Maximum QoS 1, Retain Available 0, Shared Subscription Available 0. An empty client id is assigned 16 characters.
AuthenticationEMQX's chain: the built-in database (users), then the JWT authenticator (jwt). With neither configured anyone connects, as on a fresh EMQX. A wrong password is CONNACK 0x86; a token that verifies but is expired (exp), not yet valid (nbf), fails verify_claims or has a malformed acl is 0x86; a token that does not verify, or credentials no authenticator recognises, is 0x87.
Second CONNECT with a connected client idThe old connection gets DISCONNECT 0x8E Session taken over; the new one resumes the session unless it asked for a clean start.
SUBSCRIBE / SUBACKExact filters and + / # wildcards, No Local, Subscription Identifiers. Granted QoS is at most 1. A denied filter is SUBACK 0x87; $share/… is 0x9E.
PUBLISH / PUBACKQoS 0 and 1. PUBACK is 0x00, 0x10 when nobody subscribes (EMQX's behaviour), or 0x87 when denied (the message is dropped; a denied QoS 0 publish is dropped silently). Properties (content type, response topic, correlation data, user properties, payload format indicator, message expiry interval) are forwarded. Inbound topic aliases work.
OfflineA session whose connection is gone keeps its subscriptions and queues messages (QoS 0 too, as mqtt.mqueue_store_qos0; at most 1000, oldest dropped, as mqtt.max_mqueue_len). Messages sent and not acknowledged are resent with DUP on resume. At most 32 QoS 1 messages are in flight (mqtt.max_inflight), fewer if the client's Receive Maximum is lower.
Session expirySession Expiry Interval seconds after the connection ends, on the emulator clock. 0 (or absent) ends the session with the connection.
JWT expiryWith disconnectAfterExpire (EMQX's default) a connected client is sent DISCONNECT 0x87 when its token's exp passes; with it off the connection stays and its ACL denies everything.

JWT ACL. The token's acl claim (name configurable) is asked before anything else:

  • Object form, {"pub": [...], "sub": [...], "all": [...]}: listed topics are allowed and everything else is denied.
  • List form, [{"permission": "allow" | "deny", "action": "pub" | "sub" | "all", "topic": "…", "qos"?: [0, 1], "retain"?: false}]: the first matching rule decides; a topic no rule matches falls to authorization.noMatch (allow by default, as in EMQX 5; EMQX 6 changed its default to deny).

ACL topics are MQTT filters with ${clientid} and ${username} expanded; eq <topic> compares literally. A requested filter is matched word by word as written, so the rule a/+ covers a subscription to a/# (EMQX's emqx_topic:match/2). A superuser from users skips authorization. With authorization.denyAction: "disconnect" a denied operation ends the connection with DISCONNECT 0x87 instead.

The emulator models what the broker does with a topic and nothing else: space:example is one topic level. Mapping an application's logical names onto topics is the client's job.

Routes

Every route needs Authorization: Basic <api key>:<secret>. With apiKeys empty any pair is accepted; a missing header is always refused.

RouteBehaviour
POST /api/v5/publishJSON {topic, payload, qos?: 0|1|2, retain?, payload_encoding?: "plain"|"base64", properties?}. 200 {"id": "<32 hex>"} when at least one session matched (a session that is offline counts: the message is queued); 202 {"reason_code": 16, "message": "no_matching_subscribers"} when none did. 400 {"code": "BAD_REQUEST", "message"} for a body that fails the schema (missing topic, non-string payload, qos out of range, unknown field, invalid JSON); 400 {"reason_code": 144, "message": "topic_name_invalid"} for an empty topic or one with wildcards; 400 {"reason_code": 151, "message": "packet_too_large"} above 1 MB; 415 when the body is not application/json. QoS 2 is accepted and delivered at the subscription's granted QoS.
DELETE /api/v5/clients/{clientid}Kicks the client out: a connected client gets DISCONNECT 0x98 Administrative action, and the session, its subscriptions and its queue are discarded (an offline session too). 204, or 404 {"code": "CLIENTID_NOT_FOUND", "message": "Client ID not found"}.
any, without credentials401 {"code": "AUTHORIZATION_HEADER_ERROR", "message": "Support authorization: basic/bearer "}; a wrong key or secret is 401 {"code": "BAD_API_KEY_OR_SECRET", "message": "Check api_key/api_secret"}; a bearer token is 401 BAD_TOKEN. All carry WWW-Authenticate: Basic Realm="emqx-dashboard".

Admin (beyond the standard contract)

RouteEffect
GET /__admin/clientsEvery session: clientId, username, connected, transport, sessionExpiryInterval, connectedAt, disconnectedAt, expiresAt, and counts of subscriptions, queued and inflight messages.
GET /__admin/clients/:clientIdOne session, with its subscriptions under topics.
GET /__admin/subscriptions?clientId=Subscriptions: topicFilter, granted qos, options.
GET /__admin/publishes{total, publishes}: the last 1000 publishes as metadata (topic, qos, bytes, source: "mqtt" | "http" | "admin", clientId, matched, at). Payloads are never kept.
POST /__admin/inject{topic, payload | payloadBase64, qos?: 0 | 1}: deliver raw bytes to the topic's subscribers, bypassing the REST schema and every ACL. Use it to hand a consumer a payload its parser must refuse. Answers {matched}.
POST /__admin/transport/cut{clientId?}: a network outage for one client, or for all. Live connections die with no DISCONNECT and new CONNECTs are dropped unanswered. Sessions are untouched and expire on their own schedule.
POST /__admin/transport/restore{clientId?}: end the outage. GET /__admin/transport lists what is cut.
POST /__admin/clients/:clientId/disconnect{reasonCode?}: send DISCONNECT (default 0x98) and close. Unlike the vendor's kick, the session stays.
GET / PUT /__admin/settings{apiKeys?, users?, jwt?: {secret, from?, aclClaimName?, verifyClaims?, secretBase64Encoded?, disconnectAfterExpire?} | null, authorization?: {noMatch?, denyAction?}} for the calling namespace. Reset returns to the values the runtime was created with.
POST /__admin/clockStandard. Moving the clock settles expiries at once: sessions past their interval are discarded and clients with an expired token are disconnected.
POST /__admin/reset, checkpointsStandard. Reset drops every connection and session. Restoring a checkpoint restores sessions, subscriptions and queues, and drops live connections (a checkpoint cannot hold a socket).

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets): connect_bad_credentials (CONNACK 0x86), connect_not_authorized (0x87), connect_server_unavailable (0x88), publish_unavailable (REST publish answers 503 {"reason_code": 131, "message": "failed_to_dispatch"}), api_unauthorized (every REST call answers 401). A CONNECT is the fault operation MqttConnect, so any reason code can be forced: {"operationId": "MqttConnect", "effect": "connack", "params": {"reasonCode": 151}}.

Namespaces

Parallel workers isolate themselves by namespace. HTTP: x-mockingbird-namespace, a /__admin/ns/<name> prefix on the API origin, or by API key (PUT /__admin/credentials {"credentials": {"<api key>": "<namespace>"}}). MQTT: the WebSocket URL ws://…/__admin/ns/<name>/mqtt, or the same credential map keyed by the CONNECT's username, then its client id. Unmapped clients use the default namespace.

Unverified

Taken from the EMQX 5.8.6 source or left open, not observed on a broker:

  • The message text of a schema 400 (EMQX prints its config library's error; the emulator prints {"kind": "validation_error", "path": "root.<field>", "reason": …}), of a bad base64 payload, and of an unknown path's 404.
  • EMQX coerces an integer payload to its decimal string; the emulator refuses it.
  • The CONNACK Receive Maximum is EMQX's min(client's, 32); the emulator always sends 32.
  • The is_superuser token claim is not read.

Deliberately not modelled

  • MQTT 3.1 and 3.1.1: such a CONNECT is refused with that protocol's return code 1.
  • QoS 2: the CONNACK says Maximum QoS 1, a QoS 2 subscription is granted QoS 1, and a QoS 2 publish is answered DISCONNECT 0x9B. EMQX itself supports QoS 2.
  • Retained messages: the CONNACK says Retain Available 0, a retained publish is answered DISCONNECT 0x9A, and retain: true on the REST API is 400.
  • Will messages (accepted in CONNECT, never published), shared subscriptions, enhanced authentication (AUTH), keep-alive timeouts (PINGREQ is answered; use a transport cut for a dead connection), message expiry, and outbound topic aliases.
  • Public-key and JWKS tokens, password hashing, and authenticators or authorizers other than the built-in database and JWT.
  • Clustering, the dashboard and its login tokens, the rule engine, bridges, billing, TLS and listener configuration.

API

ExportKindDescription
createRuntimefunctionThe emulator with the full service contract. Options: settings, clock, seed, adminPrefix, adminKey, onLog, sqlite. Adds attach(transport, info?, namespace?) for an MQTT connection, webSocketPath(pathname), endpoints, and stop().
EmqxAPIclassOne namespace's broker: fetch(request) for the REST API, broker for MQTT, state, reset(). Options: sqlite, now, namespace, settings, connectFault.
EMQX_PRESETSobjectEvery named fault preset.
EMQX_NAMESPACEstringThe service name, "emqx".
MQTT_CONNECT_OPERATIONstring"MqttConnect", the operation id a fault rule uses for a CONNECT.
MQTT_WEBSOCKET_PATHstring"/mqtt".
MAX_PACKET_SIZEnumber1048576, EMQX's mqtt.max_packet_size.
DEFAULT_SETTINGS, DEFAULT_JWT_AUTHENTICATORobjectsA fresh EMQX: no authenticator, no_match = allow; and the JWT authenticator's defaults.
apiKeyCredentialfunctionThe API key of a request (how credentials map to namespaces).
parseAcl, checkAclfunctionsParse an acl claim, and ask it about one publish or subscribe (allow, deny or nomatch).
ReasonCodeobjectMQTT 5 reason codes by name.
document, operationIds, supportedOperationIdsvaluesThe REST contract and its operation ids.
createServer, connectStream, serveTarget, DEFAULT_PORT, DEFAULT_MQTT_PORT (./server)NodeServe HTTP, WebSocket and TCP ({url, mqttUrl, mqttPort, wsUrl, runtime, close()}; options add port, mqttPort, host, sweepMs); an in-process MQTT stream; the serve CLI target; ports 8850 and 1883.

Part of mockingbird.

版本与发布记录

@crvouga/mockingbird-service-emqx 共发布 19 个版本,首次发布于 2026/10/11;过去 12 个月发布了 19 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.10.0latest2026/11/11今天
0.9.82026/11/038 天前
0.9.72026/11/038 天前
0.9.62026/11/038 天前
0.9.52026/11/038 天前
0.9.42026/11/038 天前

关于 @crvouga/mockingbird-service-emqx 的常见问题

@crvouga/mockingbird-service-emqx 是什么?

Stateful emulator of the EMQX 5 MQTT broker: MQTT 5 over TCP and WebSocket, password and JWT authentication with JWT ACLs, persistent sessions with offline QoS 1 queues, and the REST v5 publish and client-kick endpoints.

如何安装 @crvouga/mockingbird-service-emqx?

在项目目录运行 npm install @crvouga/mockingbird-service-emqx,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 mockingbird-emqx,可以用 npx mockingbird-emqx 直接运行。

@crvouga/mockingbird-service-emqx 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@crvouga/mockingbird-service-emqx 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crvouga/mockingbird-service-emqx 可以商用吗?

@crvouga/mockingbird-service-emqx 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@crvouga/mockingbird-service-emqx 的下载量有多少?

根据 npm 官方下载统计,@crvouga/mockingbird-service-emqx 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,962 次,日均约 99 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crvouga/mockingbird-service-emqx 还在维护吗?

最新版本 0.10.0 发布于 2026/11/11(今天);至今共发布 19 个版本,其中过去 12 个月发布了 19 个;首次发布于 2026/10/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。