@cloudamqp/amqp-client
Version:
AMQP 0-9-1 client, both for browsers (WebSocket) and node (TCP Socket)
210 lines (195 loc) • 6.78 kB
text/typescript
import { AMQPBaseClient, VERSION, MIN_FRAME_SIZE } from "./amqp-base-client.js"
import { AMQPView } from "./amqp-view.js"
import { AMQPError } from "./amqp-error.js"
import { AMQPChannel } from "./amqp-channel.js"
import { AMQPConsumer } from "./amqp-consumer.js"
import { AMQPMessage } from "./amqp-message.js"
import type { Logger } from "./types.js"
export interface AMQPWebSocketInit {
url: string
vhost?: string
username?: string
password?: string
name?: string
frameMax?: number
heartbeat?: number
channelMax?: number
logger?: Logger | null
}
/**
* WebSocket client for AMQP 0-9-1 servers.
*/
export class AMQPWebSocketClient extends AMQPBaseClient {
/** WebSocket URL to connect to. */
readonly url: string
private socket?: WebSocket | undefined
private framePos = 0
private frameSize = 0
private frameBuffer: Uint8Array
/**
* @param url to the websocket endpoint, example: wss://server/ws/amqp
* @param logger - optional logger instance, defaults to null (no logging)
*/
constructor(
url: string,
vhost?: string,
username?: string,
password?: string,
name?: string,
frameMax?: number,
heartbeat?: number,
logger?: Logger | null,
)
constructor(init: AMQPWebSocketInit)
constructor(
url: string | AMQPWebSocketInit,
vhost = "/",
username = "guest",
password = "guest",
name?: string,
frameMax = MIN_FRAME_SIZE,
heartbeat = 0,
logger?: Logger | null,
) {
let channelMax = 0
if (typeof url === "object") {
vhost = url.vhost ?? vhost
username = url.username ?? username
password = url.password ?? password
name = url.name ?? name
frameMax = url.frameMax ?? frameMax
heartbeat = url.heartbeat ?? heartbeat
channelMax = url.channelMax ?? channelMax
logger = url.logger ?? logger
url = url.url
}
super(vhost, username, password, name, AMQPWebSocketClient.platform(), frameMax, heartbeat, channelMax, logger)
this.url = url
this.frameBuffer = new Uint8Array(frameMax)
}
/**
* Establish a AMQP connection over WebSocket
*/
override connect(): Promise<AMQPBaseClient> {
this.framePos = 0
this.frameSize = 0
this.channels = [new AMQPChannel(this, 0)]
const socket = new WebSocket(this.url)
this.socket = socket
socket.binaryType = "arraybuffer"
socket.onmessage = this.handleMessage.bind(this)
return new Promise((resolve, reject) => {
this.connectPromise = [resolve, reject]
socket.addEventListener("close", reject)
socket.addEventListener("error", reject)
socket.addEventListener("open", () => {
socket.addEventListener("error", (ev: Event) => {
if (!this.closed) {
const err = new AMQPError(ev.toString(), this)
this.closed = true
this.channels.forEach((ch) => ch?.setClosed(err))
this.channels = [new AMQPChannel(this, 0)]
this.onerror(err)
this.ondisconnect?.(err)
this.socket = undefined
}
})
socket.addEventListener("close", (ev: CloseEvent) => {
const clientClosed = this.closed
this.closed = true
if (!(ev.wasClean && clientClosed)) {
const err = new AMQPError(`connection not cleanly closed (${ev.code})`, this)
this.channels.forEach((ch) => ch?.setClosed(err))
this.channels = [new AMQPChannel(this, 0)]
this.onerror(err)
if (!clientClosed) {
this.ondisconnect?.(err)
}
} else {
this.channels.forEach((ch) => ch?.setClosed())
this.channels = [new AMQPChannel(this, 0)]
}
this.socket = undefined
})
socket.send(new Uint8Array([65, 77, 81, 80, 0, 0, 9, 1]))
})
})
}
/**
* @param bytes to send
* @return fulfilled when the data is enqueued
*/
override send(bytes: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
if (this.socket) {
try {
this.socket.send(bytes)
resolve()
} catch (err) {
this.closeSocket()
reject(err)
}
} else {
reject(new AMQPError("Socket not connected", this))
}
})
}
protected override closeSocket() {
this.closed = true
if (this.socket) this.socket.close()
this.socket = undefined
}
private handleMessage(event: MessageEvent) {
const buf: ArrayBuffer = event.data
const bufView = new DataView(buf)
// A socket read can contain 0 or more frames, so find frame boundries
let bufPos = 0
while (bufPos < buf.byteLength) {
// read frame size of next frame
if (this.frameSize === 0) {
// first 7 bytes of a frame was split over two reads, this reads the second part
if (this.framePos !== 0) {
const len = 7 - this.framePos
this.frameBuffer.set(new Uint8Array(buf, bufPos, len), this.framePos)
this.frameSize = new DataView(this.frameBuffer.buffer).getInt32(bufPos + 3) + 8
this.framePos += len
bufPos += len
continue
}
// frame header is split over multiple reads, copy to frameBuffer
if (bufPos + 3 + 4 > buf.byteLength) {
const len = buf.byteLength - bufPos
this.frameBuffer.set(new Uint8Array(buf, bufPos), this.framePos)
this.framePos += len
break
}
this.frameSize = bufView.getInt32(bufPos + 3) + 8
// avoid copying if the whole frame is in the read buffer
if (buf.byteLength - bufPos >= this.frameSize) {
const view = new AMQPView(buf, bufPos, this.frameSize)
this.parseFrames(view)
bufPos += this.frameSize
this.frameSize = 0
continue
}
}
const leftOfFrame = this.frameSize - this.framePos
const copyBytes = Math.min(leftOfFrame, buf.byteLength - bufPos)
this.frameBuffer.set(new Uint8Array(buf, bufPos, copyBytes), this.framePos)
this.framePos += copyBytes
bufPos += copyBytes
if (this.framePos === this.frameSize) {
const view = new AMQPView(this.frameBuffer.buffer, 0, this.frameSize)
this.parseFrames(view)
this.frameSize = this.framePos = 0
}
}
}
static platform(): string {
if (typeof navigator !== "undefined") return navigator.userAgent
else return `${process.release.name} ${process.version} ${process.platform} ${process.arch}`
}
}
export { AMQPBaseClient, AMQPChannel, AMQPConsumer, AMQPError, AMQPMessage, AMQPView, VERSION }
export { AMQPSession } from "./amqp-session.js"
export type { AMQPSessionOptions } from "./amqp-session.js"