One npm package, queen-mq, carries both the broker client and the streaming SDK. It is
ESM-only ("type": "module"), requires Node 22 or newer, and depends on undici for its own
HTTP dispatcher so close() can release keep-alive sockets deterministically. Version 1.0.0,
aligned with the broker.
import { Queen, Admin, Stream, tokenBucketGate, slidingWindowGate } from 'queen-mq'The package also exports the default objects themselves (CLIENT_DEFAULTS, QUEUE_DEFAULTS,
CONSUME_DEFAULTS, POP_DEFAULTS, BUFFER_DEFAULTS), so an application can read a default
rather than restate it.
Constructor
new Queen(config)config is a URL string, an array of URL strings, or an options object. A string or array is
equivalent to passing { url } / { urls }; everything else takes the default below. URLs are
validated at construction, and an object with neither url nor urls throws
Must provide urls or url in configuration.
| Option | Type | Default | Effect |
|---|---|---|---|
url |
string | none | Single broker URL. Ignored when urls is present. |
urls |
string[] | none | Multiple brokers. More than one entry activates the load balancer. |
timeoutMillis |
number | 30000 |
Per-request abort deadline. |
retryAttempts |
number | 3 |
Attempts for 5xx and network failures. A 4xx is never retried by this counter. |
retryDelayMillis |
number | 1000 |
First retry delay; doubles per attempt. |
loadBalancingStrategy |
'affinity' | 'round-robin' | 'session' |
'affinity' |
Only consulted when urls.length > 1. |
affinityHashRing |
number | 128 |
Virtual nodes per backend on the consistent-hash ring. |
enableFailover |
boolean | true |
On a 5xx or network error, try the next backend. |
healthRetryAfterMillis |
number | 5000 |
How long a backend stays marked unhealthy. |
bearerToken |
string | null | null |
Sent as Authorization: Bearer <token> on every request. |
headers |
object | {} |
Extra headers on every request. |
hostHeader |
string | null | null |
Bare authority to advertise as Host, independent of the address dialled. |
handleSignals |
boolean | true |
Register SIGINT/SIGTERM handlers that flush buffers and exit. |
logger |
object | null | null |
Custom logger implementing info/warn/error. |
structuredLogs |
boolean | false |
Call the custom logger pino-style, info(fields, message). |
retry429 |
object | undefined |
HTTP 429 backoff policy. See below. |
Three consequences worth knowing before you tune anything:
loadBalancingStrategy,affinityHashRing,enableFailoverandhealthRetryAfterMillisare inert with a single URL. The load balancer is only constructed whenurlshas more than one entry; a single-URL client goes straight to oneHttpClient.handleSignals: trueinstallsprocess.exit(). The handler flushes buffers and then exits the process. Set it tofalsewhen the client is embedded in an application that owns its own shutdown, and callclose()yourself.headers: { Host }cannot work.fetch()treatshostas a forbidden header name and drops it silently. The client detects it, maps it ontohostHeader, and warns onconsole.warnregardless of the log setting, because a wrongHostat a multi-tenant proxy does not fail loudly: it lands your traffic on the wrong cluster.retryAttemptsand failover are mutually exclusive paths. With one URL, or withenableFailover: false, a call makes up toretryAttemptsattempts against the same backend (3 total by default). With several URLs and failover on,retryAttemptsis not consulted at all: the call walks the backend list, trying each at most once.
retry429
Separate from retryAttempts, which covers 5xx and network faults. This policy covers only
HTTP 429 from a proxy.
| Field | Default | Effect |
|---|---|---|
maxAttempts |
10 for ordinary requests; unbounded for a long-poll pop |
Total attempts including the first. Setting it explicitly applies the same bound to both kinds. |
baseMs |
500 |
First delay when the response carries no Retry-After. Doubles per attempt. |
capMs |
30000 |
Ceiling for the exponential delay. |
A Retry-After header (seconds) always wins over the computed delay. Every delay gets ±20%
jitter. A 429 is retried against the same backend and never triggers failover, because rate
limiting is not a health signal.
hostHeader
A queen_proxy deployment picks the tenant cluster from the first DNS label of the Host
header. When the base URL points at a shared address (an IP, a cell endpoint, a local rig),
hostHeader rewrites the request authority (and TLS SNI) while the socket still dials the
configured address, the same contract as curl --resolve. Accepts a bare authority only
('acme.eu1.example', 'acme.local:6711'); anything URL-shaped throws at construction.
Queen
| Member | Signature | Returns |
|---|---|---|
queue |
queue(name = null) |
QueueBuilder |
admin |
getter | Admin, lazily created, one per client |
transaction |
transaction() |
TransactionBuilder |
ack |
async ack(message, status = true, context = {}) |
single: one ack result object; batch: { success, processed, results } or { success: false, error, results } |
renew |
async renew(messageOrLeaseId) |
one { leaseId, success, newExpiresAt } for a single input, an array for an array input |
flushAllBuffers |
async flushAllBuffers() |
undefined |
getBufferStats |
getBufferStats() |
{ activeBuffers, totalBufferedMessages, oldestBufferAge, flushesPerformed } |
deleteConsumerGroup |
async deleteConsumerGroup(group, deleteMetadata = true) |
the broker’s response body |
updateConsumerGroupTimestamp |
async updateConsumerGroupTimestamp(group, timestamp) |
the broker’s response body |
enableGracefulShutdown |
enableGracefulShutdown() |
this. No-op if handlers are already installed |
disableGracefulShutdown |
disableGracefulShutdown() |
this |
close |
async close() |
undefined. Flushes buffers, cleans the buffer manager, removes signal handlers, destroys the HTTP dispatchers |
ack(message, status, context)
message is one message object, one transaction-id string, or an array of either. status is
true → completed, false → failed, or a status string passed through verbatim
('retry', 'dlq'). context accepts { group, error }.
partitionId is mandatory on every message object. Without it the call fails
(Message must have partitionId property to ensure message uniqueness): for a single message
as a returned { success: false, error }, for a batch as a thrown Error. A leaseId on the
message is forwarded when present.
Per-message statuses inside a batch work by tagging items with _status and _error. If any
item in the array carries either key, the client switches to per-item mode for the whole batch
and the status argument becomes only the fallback.
renew(messageOrLeaseId)
Accepts a lease-id string, a message object, or an array of either. Lease ids are deduplicated
first: with multi-partition pop every message in one batch shares a single leaseId, and one
extend call renews every claimed partition, so passing the whole message array issues one HTTP
call, not N.
QueueBuilder
queen.queue(name) returns a fresh builder. Every configuration method returns this; the
terminal methods are push(), pop(), consume(), create(), delete(), dlq() and
flushBuffer().
Addressing
| Method | Default | Notes |
|---|---|---|
name (getter) |
n/a | The queue name this builder was created with. |
partition(name) |
'Default' |
The ordered lane. A partition other than Default switches pop()/consume() to the partition-scoped route. |
namespace(name) |
null |
Grouping label, set at create time; also a pop filter. |
task(name) |
null |
Second grouping label; same dual role. |
group(name) |
null |
Consumer group. Absent, the broker uses the queue-mode group __QUEUE_MODE__. |
Queue lifecycle
| Method | Wire call | Returns |
|---|---|---|
config(options) |
none | this. Merges options over QUEUE_DEFAULTS. |
create() |
POST /api/v1/configure |
OperationBuilder |
delete() |
DELETE /api/v1/resources/queues/:queue |
OperationBuilder. Throws immediately if the builder has no queue name. |
QUEUE_DEFAULTS, the object config() merges into:
| Key | Default | Unit |
|---|---|---|
leaseTime |
300 |
seconds |
retryLimit |
3 |
attempts |
delayedProcessing |
0 |
seconds |
windowBuffer |
0 |
seconds |
retentionSeconds |
0 |
seconds |
completedRetentionSeconds |
0 |
seconds |
encryptionEnabled |
false |
n/a |
Keys you add to config() are sent verbatim, so any option /configure accepts can be set
even though it is not in the defaults object. Two things follow:
/configureis a full replace. Keys absent from the body reset to the broker’s own defaults, not to whatever the queue had before. Send the complete intended configuration every time. The full option list lives in Reference.- Calling
create()withoutconfig()sendsQUEUE_DEFAULTSas-is, which meansleaseTime: 300, whereas a queue created implicitly by a first push gets a 60-second lease.
Producing
| Method | Default | Notes |
|---|---|---|
buffer(options) |
off | { messageCount, timeMillis }, defaults 100 / 1000. Turns push() into an enqueue. |
push(payload) |
n/a | Returns a PushBuilder. payload is one item or an array. |
Each item may be { data }, { payload }, or a bare object used as the payload itself. The
client mints a UUIDv7 transactionId when the item does not carry one, and forwards traceId
only when it is a valid UUID. The wire field is always payload; data is a client-side alias.
const res = await client
.queue('test-queue-v2')
.push([{ data: { message: 'Hello, world!' } }])Deduplication is keyed on transactionId and enforced in SQL before an offset is allocated, so
a duplicate writes nothing:
const res1 = await client
.queue('test-queue-v2')
.push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
const res2 = await client
.queue('test-queue-v2')
.push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
// res1[0].status === 'queued', res2[0].status === 'duplicate'PushBuilder
Thenable: awaiting it executes the push. then() is idempotent: a second await on the same
builder resolves without re-sending.
| Method | Callback signature |
|---|---|
onSuccess(cb) |
cb(successfulItems) |
onError(cb) |
cb(failedItems, error) |
onDuplicate(cb) |
cb(duplicateItems, error) |
Awaiting a non-buffered push resolves to the broker’s per-item array. The four possible item
statuses are described in Reference. With no onError callback registered, at
least one failed item makes the await throw; with one registered, it does not throw. Awaiting a
buffered push resolves to { buffered: true, count } immediately. The messages have not been
sent yet.
Consuming
Both pop() and consume() read these; the defaults differ between the two.
| Method | pop() default |
consume() default |
Notes |
|---|---|---|---|
batch(size) |
1 |
1 |
Clamped to ≥ 1. With partitions(n), this is the total message budget across all claimed partitions. |
partitions(n) |
1 |
1 |
Claim up to N partitions in one call. All share one leaseId. Sent only when > 1. |
wait(enabled) |
false |
true |
Long-poll. |
timeoutMillis(ms) |
30000 |
30000 |
Server-side long-poll deadline. The HTTP layer adds 5 s of slack so the server times out first. Clamped to ≥ 1. |
autoAck(enabled) |
false |
true |
See the warning below: the two mean different things. |
subscriptionMode(mode) |
null |
null |
all | new. |
subscriptionFrom(from) |
null |
null |
'now' or an ISO timestamp. |
concurrency(count) |
n/a | 1 |
consume() only. Number of worker loops. Clamped to ≥ 1. |
limit(count) |
n/a | null |
consume() only. Stop after N messages per worker. |
idleMillis(ms) |
n/a | null |
consume() only. Stop after N ms without a message. |
renewLease(enabled, intervalMillis) |
n/a | false |
consume() only. Renews on an interval while the handler runs. |
each() |
n/a | batch mode | consume() only. Deliver one message per handler call. |
pop()
async pop() → array of message objects, [] when there is nothing to return.
const res = await client
.queue('test-queue-v2-pop-non-empty')
.batch(1)
.wait(true)
.pop()Route selection: a queue name with a non-Default partition uses
/api/v1/pop/queue/:queue/partition/:partition; a queue name alone uses
/api/v1/pop/queue/:queue; namespace or task without a queue uses /api/v1/pop. No queue,
namespace or task throws.
consume(handler, options)
Returns a ConsumeBuilder, thenable like the others. options.signal accepts an AbortSignal
that stops every worker at the top of its next iteration.
await client
.queue('test-queue-v2-consume')
.batch(1)
.limit(1)
.each()
.consume(async msg => {
msgToReturn = msg
})ConsumeBuilder method |
Callback signature |
|---|---|
onSuccess(cb) |
cb(msgOrMsgs, handlerResult) |
onError(cb) |
cb(msgOrMsgs, error) |
Registering either callback forces autoAck to false for the run, to prevent a
double-ack when the callback acks by hand.
Worker loop behaviour, all of it observable:
- A handler throw with
autoAckon nacks the message and does not rethrow, so the worker keeps going. - In
each()mode, a nack aborts the rest of the popped batch. The nack releases the lease and clamps the broker cursor at the failed message, so everything after it will be redelivered; continuing would only produce duplicates and rejected acks. - A 429 that escapes the HTTP layer’s own retry sleeps for
Retry-After(or 1 s) and continues. - A network error sleeps 1 s and continues.
- A 403 stops the worker and rethrows.
cluster_suspendedcannot resolve itself, and none of the other proxy 403 codes are worth hot-looping. - Any other error is rethrown.
message.trace(config)
Messages delivered through consume() are given a trace() method that posts to
/api/v1/traces:
await msg.trace({
traceName: ['tenant-acme', 'room-123'],
eventType: 'info',
data: { text: 'started processing' }
})data is required; traceName accepts a string or an array of strings; eventType defaults to
'info'. It never throws: failures return { success: false, error }.
Buffering
| Method | Notes |
|---|---|
buffer(options) |
Enable per-queue/partition buffering for this builder’s pushes. |
flushBuffer() |
async. Flush this builder’s queue/partition buffer and wait for in-flight flushes. Throws with no queue name. |
A buffer flushes when it reaches messageCount messages or timeMillis elapses since its first
message, whichever comes first. flushBuffer() cancels the timer and drains in messageCount-sized
batches. Buffers are keyed on "<queue>/<partition>", so two builders on the same
queue and partition share one buffer.
Dead-letter queue
dlq(consumerGroup = null) returns a DLQBuilder. Throws with no queue name. The builder’s
partition is inherited from the queue builder unless it is Default.
| Method | Default |
|---|---|
limit(count) |
100, clamped to ≥ 1 |
offset(count) |
0, clamped to ≥ 0 |
from(timestamp) |
none |
to(timestamp) |
none |
get() |
async → { messages, total }; { messages: [], total: 0 } on any error |
The DLQ is read-only through this builder. Replay lives on the admin façade:
admin.retryMessage(partitionId, transactionId).
TransactionBuilder
queen.transaction(). Pushes and acks bundled into one PostgreSQL transaction, all-or-nothing.
| Method | Notes |
|---|---|
ack(messages, status = 'completed', context = {}) |
messages is one message or an array. context.consumerGroup sets the group. Requires transactionId and partitionId on every message; a leaseId is collected into requiredLeases. Returns this. |
queue(queueName) |
Returns a sub-builder with .partition(key) and .push(items). push() returns the parent builder, so chains continue. |
commit() |
async. Throws Transaction has no operations to commit on an empty bundle, and throws the broker’s error when the response is not success: true. |
await client
.transaction()
.queue('test-queue-v2-txn-basic-b')
.push([{ data: { value: messages[0].data.value + 1 } }])
.ack(messages[0])
.commit()Lease ids are deduplicated before requiredLeases is sent. Item payloads follow the same
data / payload / bare-object rule as push(), and so does the id: a transactionId you set
on the item is sent, and one you omit is minted client side. That id is the deduplication key of
the pushed message, so setting it deterministically is what makes a retried transaction safe.
Admin
queen.admin (getter) or new Admin(httpClient). Every method is async and returns the
broker’s JSON body verbatim. Methods that take a params object turn it into a query string,
dropping null and undefined.
Resources
| Method | Route |
|---|---|
getOverview() |
GET /api/v1/resources/overview |
getNamespaces() |
GET /api/v1/resources/namespaces |
getTasks() |
GET /api/v1/resources/tasks |
listQueues(params = {}) |
GET /api/v1/resources/queues |
getQueue(name) |
GET /api/v1/resources/queues/:name |
Messages and traces
| Method | Route |
|---|---|
listMessages(params = {}) |
GET /api/v1/messages |
getMessage(partitionId, transactionId) |
GET /api/v1/messages/:partitionId/:transactionId |
deleteMessage(partitionId, transactionId) |
DELETE /api/v1/messages/:partitionId/:transactionId |
retryMessage(partitionId, transactionId) |
POST /api/v1/messages/:partitionId/:transactionId/retry |
getTraceNames(params = {}) |
GET /api/v1/traces/names |
getTracesByName(traceName, params = {}) |
GET /api/v1/traces/by-name/:traceName |
getTracesForMessage(partitionId, transactionId) |
GET /api/v1/traces/:partitionId/:transactionId |
Status and analytics
| Method | Route |
|---|---|
getStatus(params = {}) |
GET /api/v1/status |
getQueueStats(params = {}) |
GET /api/v1/status/queues |
getQueueDetail(name, params = {}) |
GET /api/v1/status/queues/:name |
getAnalytics(params = {}) |
GET /api/v1/status/analytics |
getSystemMetrics(params = {}) |
GET /api/v1/analytics/system-metrics |
getWorkerMetrics(params = {}) |
GET /api/v1/analytics/worker-metrics |
getPostgresStats() |
GET /api/v1/analytics/postgres-stats |
Consumer groups
| Method | Route |
|---|---|
listConsumerGroups() |
GET /api/v1/consumer-groups |
getConsumerGroup(name) |
GET /api/v1/consumer-groups/:name |
getLaggingConsumers(minLagSeconds = 60) |
GET /api/v1/consumer-groups/lagging?minLagSeconds= |
deleteConsumerGroupForQueue(group, queue, deleteMetadata = true) |
DELETE /api/v1/consumer-groups/:group/queues/:queue |
seekConsumerGroup(group, queue, options = {}) |
POST /api/v1/consumer-groups/:group/queues/:queue/seek |
refreshConsumerStats() |
POST /api/v1/stats/refresh |
seekConsumerGroup posts options as the body verbatim: { toEnd: true } or
{ timestamp: '<RFC3339>' }.
System
| Method | Route |
|---|---|
health() |
GET /health |
metrics() |
GET /metrics |
getMaintenanceMode() |
GET /api/v1/system/maintenance |
setMaintenanceMode(enabled) |
POST /api/v1/system/maintenance |
getPopMaintenanceMode() |
GET /api/v1/system/maintenance/pop |
setPopMaintenanceMode(enabled) |
POST /api/v1/system/maintenance/pop |
Streaming SDK
Stream builds an immutable operator chain over a queue and runs it against the broker’s
/streams/v1/* endpoints. Every combinator returns a new Stream.
Stream.from(queen.queue('orders'), options?) // → Stream| Group | Methods |
|---|---|
| Stateless | map(fn), filter(predicate), flatMap(fn) |
| Keying | keyBy(fn) (at most one per stream, must precede window / reduce / gate) |
| Windows | windowTumbling(opts), windowSliding(opts), windowSession(opts), windowCron(opts) (at most one per stream) |
| Reduce | reduce(fn, initial), aggregate(extractors) (require a preceding window; at most one per stream) |
| Gate | gate(fn) (at most one per stream, incompatible with windowing and reduce) |
| Sink | to(sinkQueueBuilder, opts), foreach(fn) (must be the last operator in the chain) |
| Terminal | run(runOptions) |
Window option objects share gracePeriod, idleFlushMs, eventTime, allowedLateness and
onLate ('drop' | 'include'); the size argument differs per window:
windowTumbling({ seconds }), windowSliding({ size, slide }), windowSession({ gap }),
windowCron({ every }) where every is one of 'second' | 'minute' | 'hour' | 'day' | 'week'.
gate(fn) receives (value, ctx) with ctx.state (per-key, persisted only on ALLOW),
ctx.streamTimeMs and ctx.partitionId. Returning false halts the batch: the runner acks the
allowed prefix and does not release the source lease, so the denied message and its successors
are redelivered in their original order when the lease expires.
Two ready-made gate functions ship with the package:
tokenBucketGate({ capacity, refillPerSec, costFn?, allowZeroCost? }) and
slidingWindowGate({ limit, windowSec, costFn? }).
run(runOptions)
async, resolves to a Runner.
| Option | Default | Notes |
|---|---|---|
queryId |
required | Durable identity. Missing it throws run({ queryId }) is required. |
url |
required | Broker base URL for the /streams/v1/* calls. Missing it throws. |
bearerToken |
none | Auth for the streams calls. |
batchSize |
200 |
Messages per cycle. |
maxPartitions |
4 |
Partitions leased per cycle. |
maxWaitMillis |
1000 |
Long-poll wait on the source pop. |
subscriptionMode |
none | 'all' | 'new'. |
subscriptionFrom |
none | ISO timestamp or 'now'. |
consumerGroup |
streams.<queryId> |
Override the source group. |
reset |
false |
Wipe state when the chain’s config hash no longer matches. |
onError |
none | (err, ctx) per-cycle error hook. |
abortSignal |
none | External cancellation. |
logger |
none | Custom logger. |
The chain’s shape is fingerprinted into a config hash. Re-deploying a different chain under the
same queryId is rejected at registration unless reset: true is passed. Only the operator
kinds and their structural config are hashed: user closures cannot be serialised stably, so
changing the body of a map does not change the hash.
Runner exposes stop() (graceful drain) and metrics(), which returns queryId,
serverQueryId, and the counters cyclesTotal, flushCyclesTotal, messagesTotal,
pushItemsTotal, stateOpsTotal, lateEventsTotal, errorsTotal, lastCycleAt,
lastFlushAt, lastError.
Logging
With no logger configured, logging is off unless QUEEN_CLIENT_LOG=true is set in the
environment (Node) or window.QUEEN_CLIENT_LOG === true (browser). Output goes to console as
[timestamp] [LEVEL] [operation] {json}.
A custom logger is always active, env var or not. It must implement info, warn and error;
debug falls back to info. By default it is called with one formatted string. With
structuredLogs: true it is called pino-style (info(fields, message)), where fields is the
detail object plus an operation key. Use that only with a logger that accepts a leading merge
object.
Errors
Failures throw an Error with .status (HTTP status) and, when the body carried one, .code.
The message is the body’s error string when present. A 429 also carries
.retryAfterSeconds. Proxy codes: rate_limited, quota_exceeded on 429;
cluster_suspended, storage_quota_exceeded, feature_gated, forbidden on 403. A timeout
throws with name === 'AbortError' and a .timeout field. Network faults from undici arrive as
fetch failed (ECONNREFUSED). The underlying cause is unwrapped into the message rather than
left in error.cause.
A 204 response resolves to null; the broker sends no body at all on 204.