@cloudamqp/amqp-client
Version:
AMQP 0-9-1 client, both for browsers (WebSocket) and node (TCP Socket)
718 lines (688 loc) • 27.7 kB
text/typescript
import { AMQPChannel } from "./amqp-channel.js"
import { AMQPError } from "./amqp-error.js"
import * as AMQPFrame from "./amqp-frame.js"
import { AMQPMessage } from "./amqp-message.js"
import { AMQPView } from "./amqp-view.js"
import type { Logger } from "./types.js"
export const VERSION = "4.0.0"
export const MIN_FRAME_SIZE = 8192
/**
* Base class for AMQPClients.
* Implements everything except how to connect, send data and close the socket
*/
export abstract class AMQPBaseClient {
/** Virtual host to connect to. */
vhost: string
/** Username for authentication. */
username: string
/** Password for authentication. */
password: string
/** Connection name, visible in the RabbitMQ management UI. */
name?: string
/** Platform identifier sent in client properties. */
platform?: string
/** Open channels, indexed by channel id. */
channels: AMQPChannel[]
protected connectPromise?: [(conn: AMQPBaseClient) => void, (err: Error) => void]
protected closePromise?: [(value?: void) => void, (err: Error) => void]
protected onUpdateSecretOk?: (value?: void) => void
/** Whether the connection is closed. */
closed = true
/** Set when the server has blocked publishing (connection.blocked reason). */
blocked?: string
/** Maximum number of channels negotiated with the server. */
channelMax = 0
/** Maximum frame size in bytes negotiated with the server. */
frameMax: number
/** Heartbeat interval in seconds. */
heartbeat: number
/** Callback for connection-level errors. */
onerror: (error: AMQPError) => void
/** Logger instance, or undefined to disable logging. */
logger: Logger | undefined
/** @internal Used for string -> arraybuffer when publishing. */
readonly textEncoder: InstanceType<typeof TextEncoder> = new TextEncoder()
/** @internal Buffer pool for publishes. Lets multiple microtasks publish concurrently while saving on allocations. */
readonly bufferPool: AMQPView[] = []
/**
* Callback when connection is lost
* @param error - The error that caused the disconnection, if any
*/
ondisconnect?: (error?: Error) => void
/**
* Callback when the server blocks publishing on this connection
* (typically due to a resource alarm — memory or disk).
* @param reason - The broker-supplied reason string
*/
onblocked?: (reason: string) => void
/**
* Callback when the server lifts a previous block on this connection.
*/
onunblocked?: () => void
/**
* @param name - name of the connection, set in client properties
* @param platform - used in client properties
* @param logger - optional logger instance, defaults to undefined (no logging)
*/
constructor(
vhost: string,
username: string,
password: string,
name?: string,
platform?: string,
frameMax = MIN_FRAME_SIZE,
heartbeat = 0,
channelMax = 0,
logger?: Logger | null,
) {
this.vhost = vhost
this.username = username
this.password = ""
Object.defineProperty(this, "password", {
value: password,
enumerable: false, // hide it from console.log etc.
})
if (name) this.name = name // connection name
if (platform) this.platform = platform
this.logger = logger || undefined
this.channels = [new AMQPChannel(this, 0)]
this.onerror = (error: AMQPError) => {
this.logger?.error("amqp-client connection closed", error.message)
}
if (frameMax < MIN_FRAME_SIZE) throw new Error(`frameMax must be ${MIN_FRAME_SIZE} or larger`)
this.frameMax = frameMax
if (heartbeat < 0) throw new Error("heartbeat must be positive")
this.heartbeat = heartbeat
if (channelMax && channelMax < 0) throw new Error("channelMax must be positive")
this.channelMax = channelMax
}
/**
* Open a channel
* @param [id] - An existing or non existing specific channel
*/
channel(id?: number): Promise<AMQPChannel> {
if (this.closed) return this.rejectClosed()
if (id && id > 0) {
const channel = this.channels[id]
if (channel) return Promise.resolve(channel)
}
// Store channels in an array, set position to null when channel is closed
// Look for first null value or add one the end
if (!id) id = this.channels.findIndex((ch) => ch === undefined)
if (id === -1) id = this.channels.length
if (id > this.channelMax && this.channelMax > 0)
return Promise.reject(new AMQPError("Max number of channels reached", this))
const channel = new AMQPChannel(this, id)
this.channels[id] = channel
return channel.open()
}
/**
* Gracefully close the AMQP connection.
* @param [reason] might be logged by the server
*/
close(reason = "", code = 200): Promise<void> {
if (this.closed) return this.rejectClosed()
this.closed = true
const frame = new AMQPFrame.Writer({
bufferSize: 512,
type: AMQPFrame.Type.METHOD,
channel: 0,
classId: AMQPFrame.ClassId.CONNECTION,
method: AMQPFrame.ConnectionMethod.CLOSE,
})
frame.writeUint16(code) // reply code
frame.writeShortString(reason) // reply reason
frame.writeUint16(0) // failing-class-id
frame.writeUint16(0) // failing-method-id
frame.finalize()
return new Promise((resolve, reject) => {
this.send(frame.toUint8Array())
.then(() => (this.closePromise = [resolve, reject]))
.catch(reject)
})
}
updateSecret(newSecret: string, reason: string) {
const frame = new AMQPFrame.Writer({
bufferSize: 8192,
type: AMQPFrame.Type.METHOD,
channel: 0,
classId: AMQPFrame.ClassId.CONNECTION,
method: AMQPFrame.ConnectionMethod.UPDATE_SECRET,
})
frame.writeLongString(newSecret)
frame.writeShortString(reason)
frame.finalize()
return new Promise((resolve, reject) => {
this.send(frame.toUint8Array())
.then(() => (this.onUpdateSecretOk = resolve))
.catch(reject)
})
}
/**
* Try establish a connection
*/
abstract connect(): Promise<AMQPBaseClient>
/**
* @ignore
* @param bytes to send
* @return fulfilled when the data is enqueued
*/
abstract send(bytes: Uint8Array): Promise<void>
protected abstract closeSocket(): void
private rejectClosed() {
return Promise.reject(new AMQPError("Connection closed", this))
}
private rejectConnect(err: Error): void {
if (this.connectPromise) {
const [, reject] = this.connectPromise
delete this.connectPromise
reject(err)
}
this.closed = true
this.closeSocket()
}
/**
* Parse and act on frames in an AMQPView
* @ignore
*/
protected parseFrames(view: AMQPView): void {
// Can possibly be multiple AMQP frames in a single WS frame
for (let i = 0; i < view.byteLength; ) {
const type = view.getUint8(i)
i += 1
const channelId = view.getUint16(i)
i += 2
const frameSize = view.getUint32(i)
i += 4
let frameEnd = 0
try {
frameEnd = view.getUint8(i + frameSize)
} catch {
throw new AMQPError(
`Frame end out of range, frameSize=${frameSize}, pos=${i}, byteLength=${view.byteLength}`,
this,
)
}
if (frameEnd !== AMQPFrame.End.CODE)
throw new AMQPError(`Invalid frame end ${frameEnd}, expected ${AMQPFrame.End.CODE}`, this)
const channel = this.channels[channelId]
if (!channel) {
this.logger?.warn("AMQP channel", channelId, "not open")
i += frameSize + 1
continue
}
switch (type) {
case AMQPFrame.Type.METHOD: {
const classId = view.getUint16(i)
i += 2
const methodId = view.getUint16(i)
i += 2
switch (classId) {
case AMQPFrame.ClassId.CONNECTION: {
switch (methodId) {
case AMQPFrame.ConnectionMethod.START: {
// ignore start frame, just reply startok
i += frameSize - 4
const startOk = new AMQPFrame.Writer({
bufferSize: 8192,
type: AMQPFrame.Type.METHOD,
channel: 0,
classId: AMQPFrame.ClassId.CONNECTION,
method: AMQPFrame.ConnectionMethod.START_OK,
})
const clientProps = {
connection_name: this.name || undefined,
product: "amqp-client.js",
information: "https://github.com/cloudamqp/amqp-client.js",
version: VERSION,
platform: this.platform,
capabilities: {
authentication_failure_close: true,
"basic.nack": true,
"connection.blocked": true,
consumer_cancel_notify: true,
exchange_exchange_bindings: true,
per_consumer_qos: true,
publisher_confirms: true,
},
}
startOk.writeTable(clientProps)
startOk.writeShortString("PLAIN") // authentication mechanism
const response = `\u0000${this.username}\u0000${this.password}`
startOk.writeLongString(response) // authentication response
startOk.writeShortString("") // locale
startOk.finalize()
this.send(startOk.toUint8Array()).catch(this.rejectConnect)
break
}
case AMQPFrame.ConnectionMethod.TUNE: {
const channelMax = view.getUint16(i)
i += 2
const frameMax = view.getUint32(i)
i += 4
const heartbeat = view.getUint16(i)
i += 2
this.channelMax = this.channelMax === 0 ? channelMax : Math.min(this.channelMax, channelMax)
this.frameMax = this.frameMax === 0 ? frameMax : Math.min(this.frameMax, frameMax)
this.heartbeat = this.heartbeat === 0 ? 0 : Math.min(this.heartbeat, heartbeat)
const tuneOk = new AMQPFrame.Writer({
bufferSize: 20,
type: AMQPFrame.Type.METHOD,
channel: 0,
frameSize: 12,
classId: AMQPFrame.ClassId.CONNECTION,
method: AMQPFrame.ConnectionMethod.TUNE_OK,
})
tuneOk.writeUint16(this.channelMax)
tuneOk.writeUint32(this.frameMax)
tuneOk.writeUint16(this.heartbeat)
tuneOk.finalize()
this.send(tuneOk.toUint8Array()).catch(this.rejectConnect)
const open = new AMQPFrame.Writer({
bufferSize: 512,
type: AMQPFrame.Type.METHOD,
channel: 0,
classId: AMQPFrame.ClassId.CONNECTION,
method: AMQPFrame.ConnectionMethod.OPEN,
})
open.writeShortString(this.vhost)
open.writeUint8(0) // reserved1
open.writeUint8(0) // reserved2
open.finalize()
this.send(open.toUint8Array()).catch(this.rejectConnect)
break
}
case AMQPFrame.ConnectionMethod.OPEN_OK: {
i += 1 // reserved1
this.closed = false
const promise = this.connectPromise
if (promise) {
const [resolve] = promise
delete this.connectPromise
resolve(this)
}
break
}
case AMQPFrame.ConnectionMethod.CLOSE: {
const code = view.getUint16(i)
i += 2
const [text, strLen] = view.getShortString(i)
i += strLen
const classId = view.getUint16(i)
i += 2
const methodId = view.getUint16(i)
i += 2
this.logger?.debug("connection closed by server", code, text, classId, methodId)
const msg = `connection closed: ${text} (${code})`
const err = new AMQPError(msg, this)
this.channels.forEach((ch) => ch.setClosed(err))
this.channels = [new AMQPChannel(this, 0)]
const closeOk = new AMQPFrame.Writer({
bufferSize: 12,
type: AMQPFrame.Type.METHOD,
channel: 0,
frameSize: 4,
classId: AMQPFrame.ClassId.CONNECTION,
method: AMQPFrame.ConnectionMethod.CLOSE_OK,
})
closeOk.finalize()
this.send(closeOk.toUint8Array()).catch((err) =>
this.logger?.warn("Error while sending Connection#CloseOk", err),
)
this.onerror(err)
this.ondisconnect?.(err)
this.rejectConnect(err)
this.onUpdateSecretOk?.()
break
}
case AMQPFrame.ConnectionMethod.CLOSE_OK: {
this.channels.forEach((ch) => ch.setClosed())
this.channels = [new AMQPChannel(this, 0)]
const promise = this.closePromise
if (promise) {
const [resolve] = promise
delete this.closePromise
resolve()
this.closeSocket()
}
break
}
case AMQPFrame.ConnectionMethod.BLOCKED: {
const [reason, len] = view.getShortString(i)
i += len
this.logger?.warn("AMQP connection blocked:", reason)
this.blocked = reason
this.onblocked?.(reason)
break
}
case AMQPFrame.ConnectionMethod.UNBLOCKED: {
this.logger?.info("AMQP connection unblocked")
delete this.blocked
this.onunblocked?.()
break
}
case AMQPFrame.ConnectionMethod.UPDATE_SECRET_OK: {
this.logger?.info("AMQP connection update secret ok")
this.onUpdateSecretOk?.()
delete this.onUpdateSecretOk
break
}
default:
i += frameSize - 4
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
case AMQPFrame.ClassId.CHANNEL: {
switch (methodId) {
case AMQPFrame.ChannelMethod.OPEN_OK: {
i += 4 // reserved1 (long string)
channel.resolveRPC(channel)
break
}
case AMQPFrame.ChannelMethod.FLOW_OK: {
const active = view.getUint8(i) !== 0
i += 1
channel.resolveRPC(active)
break
}
case AMQPFrame.ChannelMethod.CLOSE: {
const code = view.getUint16(i)
i += 2
const [text, strLen] = view.getShortString(i)
i += strLen
const classId = view.getUint16(i)
i += 2
const methodId = view.getUint16(i)
i += 2
this.logger?.debug("channel", channelId, "closed", code, text, classId, methodId)
const msg = `channel ${channelId} closed: ${text} (${code})`
const err = new AMQPError(msg, this)
channel.setClosed(err)
delete this.channels[channelId]
const closeOk = new AMQPFrame.Writer({
bufferSize: 12,
type: AMQPFrame.Type.METHOD,
channel: channelId,
frameSize: 4,
classId: AMQPFrame.ClassId.CHANNEL,
method: AMQPFrame.ChannelMethod.CLOSE_OK,
})
closeOk.finalize()
this.send(closeOk.toUint8Array()).catch((err) =>
this.logger?.error("Error while sending Channel#closeOk", err),
)
break
}
case AMQPFrame.ChannelMethod.CLOSE_OK: {
channel.setClosed()
delete this.channels[channelId]
channel.resolveRPC()
break
}
default:
i += frameSize - 4 // skip rest of frame
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
case AMQPFrame.ClassId.EXCHANGE: {
switch (methodId) {
case AMQPFrame.ExchangeMethod.DECLARE_OK:
case AMQPFrame.ExchangeMethod.DELETE_OK:
case AMQPFrame.ExchangeMethod.BIND_OK:
case AMQPFrame.ExchangeMethod.UNBIND_OK: {
channel.resolveRPC()
break
}
default:
i += frameSize - 4 // skip rest of frame
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
case AMQPFrame.ClassId.QUEUE: {
switch (methodId) {
case AMQPFrame.QueueMethod.DECLARE_OK: {
const [name, strLen] = view.getShortString(i)
i += strLen
const messageCount = view.getUint32(i)
i += 4
const consumerCount = view.getUint32(i)
i += 4
channel.resolveRPC({ name, messageCount, consumerCount })
break
}
case AMQPFrame.QueueMethod.BIND_OK: {
channel.resolveRPC()
break
}
case AMQPFrame.QueueMethod.PURGE_OK: {
const messageCount = view.getUint32(i)
i += 4
channel.resolveRPC({ messageCount })
break
}
case AMQPFrame.QueueMethod.DELETE_OK: {
const messageCount = view.getUint32(i)
i += 4
channel.resolveRPC({ messageCount })
break
}
case AMQPFrame.QueueMethod.UNBIND_OK: {
channel.resolveRPC()
break
}
default:
i += frameSize - 4
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
case AMQPFrame.ClassId.BASIC: {
switch (methodId) {
case AMQPFrame.BasicMethod.QOS_OK: {
channel.resolveRPC()
break
}
case AMQPFrame.BasicMethod.CONSUME_OK: {
const [consumerTag, len] = view.getShortString(i)
i += len
channel.resolveRPC(consumerTag)
break
}
case AMQPFrame.BasicMethod.CANCEL: {
const [consumerTag, len] = view.getShortString(i)
i += len
const noWait = view.getUint8(i) === 1
i += 1
const consumer = channel.consumers.get(consumerTag)
if (consumer) {
consumer.setClosed(new AMQPError("Consumer cancelled by the server", this))
channel.consumers.delete(consumerTag)
}
if (!noWait) {
const frame = new AMQPFrame.Writer({
bufferSize: 512,
type: AMQPFrame.Type.METHOD,
channel: channel.id,
classId: AMQPFrame.ClassId.BASIC,
method: AMQPFrame.BasicMethod.CANCEL_OK,
})
frame.writeShortString(consumerTag)
frame.finalize()
this.send(frame.toUint8Array())
}
break
}
case AMQPFrame.BasicMethod.CANCEL_OK: {
const [consumerTag, len] = view.getShortString(i)
i += len
channel.resolveRPC(consumerTag)
break
}
case AMQPFrame.BasicMethod.RETURN: {
const code = view.getUint16(i)
i += 2
const [text, len] = view.getShortString(i)
i += len
const [exchange, exchangeLen] = view.getShortString(i)
i += exchangeLen
const [routingKey, routingKeyLen] = view.getShortString(i)
i += routingKeyLen
const message = new AMQPMessage(channel)
message.exchange = exchange
message.routingKey = routingKey
message.replyCode = code
message.replyText = text
channel.returned = message
break
}
case AMQPFrame.BasicMethod.DELIVER: {
const [consumerTag, consumerTagLen] = view.getShortString(i)
i += consumerTagLen
const deliveryTag = view.getUint64(i)
i += 8
const redelivered = view.getUint8(i) === 1
i += 1
const [exchange, exchangeLen] = view.getShortString(i)
i += exchangeLen
const [routingKey, routingKeyLen] = view.getShortString(i)
i += routingKeyLen
const message = new AMQPMessage(channel)
message.consumerTag = consumerTag
message.deliveryTag = deliveryTag
message.exchange = exchange
message.routingKey = routingKey
message.redelivered = redelivered
channel.delivery = message
break
}
case AMQPFrame.BasicMethod.GET_OK: {
const deliveryTag = view.getUint64(i)
i += 8
const redelivered = view.getUint8(i) === 1
i += 1
const [exchange, exchangeLen] = view.getShortString(i)
i += exchangeLen
const [routingKey, routingKeyLen] = view.getShortString(i)
i += routingKeyLen
const messageCount = view.getUint32(i)
i += 4
const message = new AMQPMessage(channel)
message.deliveryTag = deliveryTag
message.redelivered = redelivered
message.exchange = exchange
message.routingKey = routingKey
message.messageCount = messageCount
channel.getMessage = message
break
}
case AMQPFrame.BasicMethod.GET_EMPTY: {
const [, len] = view.getShortString(i)
i += len // reserved1
channel.resolveRPC(null)
break
}
case AMQPFrame.BasicMethod.ACK: {
const deliveryTag = view.getUint64(i)
i += 8
const multiple = view.getUint8(i) === 1
i += 1
channel.publishConfirmed(deliveryTag, multiple, false)
break
}
case AMQPFrame.BasicMethod.RECOVER_OK: {
channel.resolveRPC()
break
}
case AMQPFrame.BasicMethod.NACK: {
const deliveryTag = view.getUint64(i)
i += 8
const multiple = view.getUint8(i) === 1
i += 1
channel.publishConfirmed(deliveryTag, multiple, true)
break
}
default:
i += frameSize - 4
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
case AMQPFrame.ClassId.CONFIRM: {
switch (methodId) {
case AMQPFrame.ConfirmMethod.SELECT_OK: {
channel.confirmId = 1
channel.resolveRPC()
break
}
default:
i += frameSize - 4
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
case AMQPFrame.ClassId.TX: {
switch (methodId) {
case AMQPFrame.TxMethod.SELECT_OK:
case AMQPFrame.TxMethod.COMMIT_OK:
case AMQPFrame.TxMethod.ROLLBACK_OK: {
channel.resolveRPC()
break
}
default:
i += frameSize - 4
this.logger?.error("unsupported class/method id", classId, methodId)
}
break
}
default:
i += frameSize - 2
this.logger?.error("unsupported class id", classId)
}
break
}
case AMQPFrame.Type.HEADER: {
i += 4 // ignoring class id and weight
const bodySize = view.getUint64(i)
i += 8
const [properties, propLen] = view.getProperties(i)
i += propLen
const message = channel.delivery || channel.getMessage || channel.returned
if (message) {
message.bodySize = bodySize
message.properties = properties
message._rawBytes = new Uint8Array(bodySize)
if (bodySize === 0) channel.onMessageReady(message)
} else {
this.logger?.warn("Header frame but no message")
}
break
}
case AMQPFrame.Type.BODY: {
const message = channel.delivery || channel.getMessage || channel.returned
if (message && message._rawBytes) {
const bodyPart = new Uint8Array(view.buffer, view.byteOffset + i, frameSize)
message._rawBytes.set(bodyPart, message.bodyPos)
message.bodyPos += frameSize
i += frameSize
if (message.bodyPos === message.bodySize) channel.onMessageReady(message)
} else {
this.logger?.warn("Body frame but no message")
}
break
}
case AMQPFrame.Type.HEARTBEAT: {
const heartbeat = new Uint8Array([AMQPFrame.Type.HEARTBEAT, 0, 0, 0, 0, 0, 0, AMQPFrame.End.CODE])
this.send(heartbeat).catch((err) => this.logger?.warn("Error while sending heartbeat", err))
break
}
default:
this.logger?.error("invalid frame type:", type)
i += frameSize
}
i += 1 // frame end
}
}
}