├── .eslintignore ├── .github ├── code_of_conduct.md ├── contributing.md └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── README.md ├── docs ├── 01-connecting-to-server.md ├── 02-bring-your-own-transport.md ├── 03-common-pitfalls.md ├── 04-browser-compatibility.md ├── 05-command-line.md ├── recipes │ ├── connection-via-proxy.md │ ├── electron-app.md │ ├── react.md │ ├── typescript.md │ ├── using-custom-transports.md │ ├── using-logs-to-debug.md │ └── websockets.md └── support-statement.md ├── media └── header.png ├── package-lock.json ├── package.json ├── sample └── sample.js ├── src ├── client.ts ├── connectionFactory │ ├── buildWebSocketStream.ts │ ├── index.ts │ └── interfaces │ │ ├── webSocketOptions.ts │ │ └── webSocketStream.ts ├── defaultClientId.ts ├── index.ts ├── interface │ ├── clientOptions.ts │ ├── connectOptions.ts │ └── wsOptions.ts ├── sequencer.ts ├── store.ts ├── util │ ├── constants.ts │ ├── defaultClientId.ts │ ├── error.ts │ ├── logger.ts │ ├── reasonCodes.ts │ ├── returnCodes.ts │ ├── sleep.ts │ └── validateTopic.ts └── write.ts ├── test ├── test_connect.js ├── test_disconnect.js ├── test_option_validation.js ├── test_publish.js ├── test_timeout.js └── util │ ├── generate_unique_port_number.js │ └── testing_server_factory.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | -------------------------------------------------------------------------------- /.github/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | Translations: N/A 4 | 5 | ## Our Pledge 6 | 7 | In the interest of fostering an open and welcoming environment, we as 8 | contributors and maintainers pledge to making participation in our project and 9 | our community a harassment-free experience for everyone, regardless of age, body 10 | size, disability, ethnicity, gender identity and expression, level of experience, 11 | nationality, personal appearance, race, religion, or sexual identity and 12 | orientation. 13 | 14 | ## Our Standards 15 | 16 | Examples of behavior that contributes to creating a positive environment 17 | include: 18 | 19 | * Using welcoming and inclusive language 20 | * Being respectful of differing viewpoints and experiences 21 | * Gracefully accepting constructive criticism 22 | * Focusing on what is best for the community 23 | * Showing empathy towards other community members 24 | 25 | Examples of unacceptable behavior by participants include: 26 | 27 | * The use of sexualized language or imagery and unwelcome sexual attention or 28 | advances 29 | * Trolling, insulting/derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or electronic 32 | address, without explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying the standards of acceptable 39 | behavior and are expected to take appropriate and fair corrective action in 40 | response to any instances of unacceptable behavior. 41 | 42 | Project maintainers have the right and responsibility to remove, edit, or 43 | reject comments, commits, code, wiki edits, issues, and other contributions 44 | that are not aligned to this Code of Conduct, or to ban temporarily or 45 | permanently any contributor for other behaviors that they deem inappropriate, 46 | threatening, offensive, or harmful. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies both within project spaces and in public spaces 51 | when an individual is representing the project or its community. Examples of 52 | representing a project or community include using an official project e-mail 53 | address, posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. Representation of a project may be 55 | further defined and clarified by project maintainers. 56 | 57 | ## Enforcement 58 | 59 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 60 | reported by contacting the project team at sindresorhus@gmail.com. All 61 | complaints will be reviewed and investigated and will result in a response that 62 | is deemed necessary and appropriate to the circumstances. The project team is 63 | obligated to maintain confidentiality with regard to the reporter of an incident. 64 | Further details of specific enforcement policies may be posted separately. 65 | 66 | Project maintainers who do not follow or enforce the Code of Conduct in good 67 | faith may face temporary or permanent repercussions as determined by other 68 | members of the project's leadership. 69 | 70 | ## Attribution 71 | 72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 73 | available at [http://contributor-covenant.org/version/1/4][version] 74 | 75 | [homepage]: http://contributor-covenant.org 76 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to MQTT.js 2 | 3 | ✨ Thanks for contributing to MQTT.js! ✨ 4 | 5 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 6 | 7 | Translations: N/A 8 | 9 | ## How can I contribute? 10 | 11 | ### Improve documentation 12 | 13 | ### Improve issues 14 | 15 | ### Give feedback on issues 16 | 17 | ### Help out 18 | 19 | ### Hang out and chat 20 | 21 | ## Contributing code 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Install and test MQTT.js 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | paths-ignore: 8 | - "*.md" 9 | - "docs/**" 10 | - "media/**" 11 | jobs: 12 | nodejs: 13 | name: Node.js 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | node-version: [^14.17, ^16.4, ^17] 19 | os: [ubuntu-latest, windows-latest] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Enable symlinks 23 | if: matrix.os == 'windows-latest' 24 | run: | 25 | git config core.symlinks true 26 | git reset --hard 27 | - uses: actions/setup-node@v2 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: npm 31 | - name: Upgrade npm 32 | run: npm install --global npm@^8.1.2 33 | - run: npm ci --no-audit 34 | - run: npm run test 35 | name: ${{ matrix.os }}/${{ matrix.node-version }} 36 | 37 | lockfile_churn: 38 | name: Test package-lock for unexpected modifications 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v2 42 | - uses: actions/setup-node@v2 43 | with: 44 | node-version: ^16.4 45 | cache: npm 46 | - name: Upgrade npm 47 | run: if [[ "$(npm -v)" != "8.1.2" ]]; then npm install --global npm@8.1.2; fi 48 | - run: npm install --no-audit --lockfile-version=3 49 | - name: Test package-lock for unexpected modifications 50 | run: | 51 | npm -v 52 | checksum=$(md5sum package-lock.json) 53 | npm install --package-lock-only --no-audit 54 | if ! echo ${checksum} | md5sum --quiet -c -; then 55 | echo "package-lock.json was modified unexpectedly. Please rebuild it using npm@$(npm -v) and commit the changes." 56 | exit 1 57 | fi 58 | 59 | without_lockfile: 60 | name: Install dependencies without using a lockfile 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v2 64 | - uses: actions/setup-node@v2 65 | with: 66 | node-version: ^16.4 67 | - name: Upgrade npm 68 | run: npm install --global npm@^8.1.2 69 | - run: npm install --no-shrinkwrap --no-audit 70 | 71 | linting: 72 | name: Lint source files 73 | runs-on: ubuntu-latest 74 | steps: 75 | - uses: actions/checkout@v2 76 | - uses: actions/setup-node@v2 77 | with: 78 | node-version: ^16.4 79 | cache: npm 80 | - name: Upgrade npm 81 | run: npm install --global npm@^8.1.2 82 | - run: npm ci --no-audit 83 | - run: npm run lint 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | dist/**/* 39 | 40 | # VSCode 41 | .vscode 42 | 43 | # NYC 44 | .nyc_output 45 | 46 | # dist 47 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: true 2 | singleQuote: true 3 | printWidth: 132 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MQTT.js logo 2 | 3 | **NOTE: Development on this repository is on hold. Please see [this issue](https://github.com/mqttjs/mqttjs-v5/issues/11) for more information.** 4 | 5 | This is the development repository for the next generation of MQTT.js, an MQTT 6 | client for JavaScript with a simple API, detailed error output, and modern 7 | language features that lets you create cutting edge MQTT-based applications 🚀 8 | -------------------------------------------------------------------------------- /docs/01-connecting-to-server.md: -------------------------------------------------------------------------------- 1 | # Connecting To Server 2 | 3 | Translations: N/A 4 | 5 | ## Recommended Secure Transports 6 | 7 | MQTT.js supports MQTT over TCP, however it is only recommended for prototyping and testing purposes. For all other uses, it is recommended to use a secure transport layer like TLS or QUIC. 8 | 9 | Though it is possible to create a custom transport and provide it to the client, the client also offers some default secure transports: 10 | 11 | - MQTT over TLS 12 | - MQTT over Websockets over TLS 13 | 14 | To clarify the user experience, MQTT accepts the brokerURL as a WHATWG URL type, which is cross-compatible over supported Node.js verions or Browser environments. 15 | 16 | ## Promise / Callback support 17 | 18 | MQTT.js no longer supports callbacks for it's API. It is implemented with promises as a first-class API surface. **NOTE**: If there is significant community feedback in favor of adding a callback API, that can be considered. 19 | 20 | ## Async function support 21 | 22 | MQTT.js comes with built-in support for [async functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function). 23 | 24 | ## Observable support 25 | 26 | TODO: Should we support observables? 27 | 28 | MQTT.js could come with built-in support for [observables](https://github.com/zenparsing/es-observable). 29 | -------------------------------------------------------------------------------- /docs/02-bring-your-own-transport.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/docs/02-bring-your-own-transport.md -------------------------------------------------------------------------------- /docs/03-common-pitfalls.md: -------------------------------------------------------------------------------- 1 | # Common Pitfalls -------------------------------------------------------------------------------- /docs/04-browser-compatibility.md: -------------------------------------------------------------------------------- 1 | # Browser Compatibility -------------------------------------------------------------------------------- /docs/05-command-line.md: -------------------------------------------------------------------------------- 1 | # Command Line 2 | 3 | MQTT.js can be run via the command line. To use, follow these steps: 4 | 5 | ```js 6 | // TODO: Command Line Example. 7 | ``` -------------------------------------------------------------------------------- /docs/recipes/connection-via-proxy.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/docs/recipes/connection-via-proxy.md -------------------------------------------------------------------------------- /docs/recipes/electron-app.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/docs/recipes/electron-app.md -------------------------------------------------------------------------------- /docs/recipes/react.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/docs/recipes/react.md -------------------------------------------------------------------------------- /docs/recipes/typescript.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/docs/recipes/typescript.md -------------------------------------------------------------------------------- /docs/recipes/using-custom-transports.md: -------------------------------------------------------------------------------- 1 | # Using Custom Transports 2 | 3 | MQTT.js supports a BYOT (Bring Your Own Transport) approach to enable more complex Transport scenarios, such as country / company specific proxies. Your Transport must conform to the API set forth for all custom transports. 4 | 5 | ```js 6 | // TODO: Write sample of using custom transport. Maybe create ali.js for AliExpress as example since it is in old library. 7 | ``` -------------------------------------------------------------------------------- /docs/recipes/using-logs-to-debug.md: -------------------------------------------------------------------------------- 1 | # Using Logs to Debug 2 | 3 | MQTT.js utilizes `pino` for logging. To adjust `pino` logging, refer to the `pino` documentation directly. -------------------------------------------------------------------------------- /docs/recipes/websockets.md: -------------------------------------------------------------------------------- 1 | # Websockets 2 | 3 | MQTT.js supports MQTT over Websockets. Using it is simple: 4 | 5 | ```js 6 | // TODO: Update after implementing websockets. 7 | ``` -------------------------------------------------------------------------------- /docs/support-statement.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/docs/support-statement.md -------------------------------------------------------------------------------- /media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqttjs/mqttjs-v5/45a11b838853cde82abfe33f52873ad74a599b0d/media/header.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "//": "TODO: author, contributors", 3 | "name": "mqtt", 4 | "version": "5.0.0", 5 | "description": "The next generation of the MQTT.js client", 6 | "keywords": [ 7 | "mqtt", 8 | "publish", 9 | "subscribe", 10 | "publish-subscribe", 11 | "publish/subscribe", 12 | "client", 13 | "broker", 14 | "server" 15 | ], 16 | "homepage": "https://github.com/mqttjs/mqttjs-v5#readme", 17 | "bugs": "https://github.com/mqttjs/issues", 18 | "license": "MIT", 19 | "type": "module", 20 | "engines": { 21 | "node": ">=14.13.1" 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "main": "./dist/index.js", 27 | "types": "./dist/index.d.ts", 28 | "scripts": { 29 | "build": "tsc", 30 | "test": "npm run build && c8 ava -r pino-debug | pino-pretty", 31 | "lint": "eslint .", 32 | "prettier": "prettier 'src/**/*.ts' 'test/**/*.js' --write" 33 | }, 34 | "dependencies": { 35 | "duplexify": "^4.1.2", 36 | "end-of-stream": "^1.4.4", 37 | "mqtt-packet": "^7.1.1", 38 | "number-allocator": "^1.0.10", 39 | "pino": "^7.6.3", 40 | "ws": "^8.4.2" 41 | }, 42 | "devDependencies": { 43 | "@types/duplexify": "^3.6.1", 44 | "@types/end-of-stream": "^1.4.1", 45 | "@types/node": "^14.18.10", 46 | "@types/ws": "^8.2.2", 47 | "@typescript-eslint/eslint-plugin": "^5.12.1", 48 | "@typescript-eslint/parser": "^5.12.1", 49 | "aedes": "^0.46.2", 50 | "ava": "^4.3.0", 51 | "c8": "^7.11.0", 52 | "eslint": "^7.21.0", 53 | "eslint-config-prettier": "^8.4.0", 54 | "eslint-plugin-prettier": "^4.0.0", 55 | "pino-debug": "^2.0.0", 56 | "pino-pretty": "^7.5.0", 57 | "prettier": "^2.5.1", 58 | "typescript": "^4.7.0" 59 | }, 60 | "ava": { 61 | "files": [ 62 | "test/**/*", 63 | "!test/util" 64 | ], 65 | "concurrency": 5, 66 | "failFast": false, 67 | "failWithoutAssertions": false, 68 | "environmentVariables": { 69 | "DEBUG": "mqtt-packet:writeToStream" 70 | }, 71 | "verbose": true, 72 | "nodeArguments": [ 73 | "--trace-deprecation", 74 | "--napi-modules" 75 | ] 76 | }, 77 | "eslintConfig": { 78 | "root": true, 79 | "parser": "@typescript-eslint/parser", 80 | "plugins": [ 81 | "@typescript-eslint", 82 | "prettier" 83 | ], 84 | "extends": [ 85 | "eslint:recommended", 86 | "plugin:@typescript-eslint/eslint-recommended", 87 | "plugin:@typescript-eslint/recommended", 88 | "prettier" 89 | ], 90 | "rules": { 91 | "@typescript-eslint/no-empty-function": 0, 92 | "@typescript-eslint/no-explicit-any": 0, 93 | "@typescript-eslint/no-unused-vars": 0 94 | } 95 | }, 96 | "c8": { 97 | "reporter": [ 98 | "html", 99 | "text", 100 | "lcov" 101 | ], 102 | "extensions": [ 103 | ".ts" 104 | ], 105 | "include": [ 106 | "**/src/**/*.ts", 107 | "**/dist/**/*.js" 108 | ], 109 | "exclude": [ 110 | "**/interfaces/", 111 | "**/interface/" 112 | ], 113 | "all": true, 114 | "check-coverage": true, 115 | "lines": 56, 116 | "functions": 58, 117 | "branches": 45, 118 | "statements": 56 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /sample/sample.js: -------------------------------------------------------------------------------- 1 | import { connect } from '../dist/index.js' 2 | import { URL } from 'url' 3 | import { aedes } from 'aedes' 4 | import { createServer } from 'net' 5 | import { logger } from './src/util/logger' 6 | 7 | 8 | const port = 1883 9 | const broker = aedes() 10 | const server = createServer(broker.handle) 11 | 12 | await new Promise(resolve => server.listen(port, resolve)) 13 | 14 | logger.test(`server listening on port ${port}`) 15 | broker.on('clientError', (client, err) => { 16 | logger.test('client error', client.id, err.message, err.stack) 17 | }) 18 | broker.on('connectionError', (client, err) => { 19 | logger.test('connection error', client, err.message, err.stack) 20 | }) 21 | broker.on('subscribe', (subscriptions, client) => { 22 | if (client) { 23 | logger.test(`subscribe from client: ${subscriptions}, ${client.id}`) 24 | } 25 | }) 26 | broker.on('publish', (_packet, client) => { 27 | if (client) { 28 | logger.test(`message from client: ${client.id}`) 29 | } 30 | }) 31 | broker.on('client', (client) => { 32 | logger.test(`new client: ${client.id}`) 33 | }) 34 | broker.preConnect = (_client, packet, callback) => { 35 | broker.emit('connectReceived', packet) 36 | callback(null, true) 37 | } 38 | 39 | 40 | const client = await connect({ brokerUrl: new URL(`mqtt://test.mosquitto.org`)}); 41 | client.publish({topic: 'test', message: 'test'}); 42 | client.disconnect(); 43 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IPacket, 3 | IConnackPacket, 4 | IConnectPacket, 5 | IDisconnectPacket, 6 | IPublishPacket, 7 | Packet, 8 | parser as mqttParser, 9 | Parser as MqttParser, 10 | } from 'mqtt-packet'; 11 | import { write } from './write.js'; 12 | import { ConnectOptions } from './interface/connectOptions.js'; 13 | import { Duplex } from 'node:stream'; 14 | import { Socket } from 'node:net'; 15 | import { EventEmitter } from 'node:events'; 16 | import { connectionFactory } from './connectionFactory/index.js'; 17 | import eos from 'end-of-stream'; 18 | import { defaultConnectOptions } from './util/constants.js'; 19 | import { ReasonCodeErrors } from './util/reasonCodes.js'; 20 | import { logger } from './util/logger.js'; 21 | import { defaultClientId } from './util/defaultClientId.js'; 22 | import { PublishPacket } from './interface/packets.js'; 23 | import { Logger } from 'pino'; 24 | import * as sequencer from './sequencer.js'; 25 | 26 | function eosPromisified(stream: NodeJS.ReadableStream | NodeJS.WritableStream): Promise { 27 | return new Promise((resolve, reject) => { 28 | eos(stream, (err: any) => (err instanceof Error ? reject(err) : resolve())); 29 | }); 30 | } 31 | 32 | export class MqttClient extends EventEmitter { 33 | _incomingPacketParser: MqttParser; 34 | _options: ConnectOptions; 35 | disconnecting: any; 36 | connecting: boolean; 37 | connected: boolean; 38 | errored: boolean; 39 | _eos: Promise | undefined; 40 | conn: Duplex | Socket; 41 | _clientLogger: Logger; 42 | /** 43 | * Use packet ID as key if there is one (e.g., SUBACK) 44 | * Use packet type as key if there is no packet ID (e.g., CONNACK) 45 | */ 46 | // TODO: This should be removed after we remove CONNECT into the sequencer 47 | _inflightPackets: Map void>; 48 | private _packetSequencer = new sequencer.MqttPacketSequencer(write.bind(null, this)); 49 | 50 | constructor(options: ConnectOptions) { 51 | super(); 52 | // assume the options have been validated before instantiating the client. 53 | this.connecting = false; 54 | this.connected = false; 55 | this.errored = false; 56 | this.disconnecting = false; 57 | this._inflightPackets = new Map(); 58 | 59 | // Using this method to clean up the constructor to do options handling 60 | logger.trace(`populating internal client options object...`); 61 | this._options = { 62 | clientId: defaultClientId(), 63 | ...defaultConnectOptions, 64 | ...options, 65 | }; 66 | 67 | this._clientLogger = logger.child({ id: this._options.clientId }); 68 | 69 | this.conn = this._options.customStreamFactory 70 | ? this._options.customStreamFactory(this._options) 71 | : connectionFactory(this._options); 72 | 73 | // many drain listeners are needed for qos 1 callbacks if the connection is intermittent 74 | this.conn.setMaxListeners(1000); 75 | 76 | this._incomingPacketParser = mqttParser(this._options); 77 | 78 | // Handle incoming packets this are parsed 79 | // NOTE: This is only handling incoming packets from the 80 | // readable stream of the conn stream. 81 | // we need to make sure that the function called on 'packet' is bound to the context of 'MQTTClient' 82 | this._incomingPacketParser.on('packet', this.handleIncomingPacket.bind(this)); 83 | 84 | // Echo connection errors this.emit('clientError') 85 | // We could look at maybe pushing errors in different directions depending on how we should 86 | // respond to the different errors. 87 | this._incomingPacketParser.on('error', (err: any) => { 88 | this._clientLogger.error(`error in incomingPacketParser.`); 89 | this.emit('clientError', err); 90 | }); 91 | 92 | this.once('connected', () => { 93 | this._clientLogger.trace(`client is connected.`); 94 | }); 95 | this.on('close', () => { 96 | this._clientLogger.trace(`client is closed.`); 97 | this.connected = false; 98 | }); 99 | 100 | this.conn.on('readable', () => { 101 | this._clientLogger.trace(`data available to be read from the 'conn' stream...`); 102 | let data = this.conn.read(); 103 | 104 | while (data) { 105 | this._clientLogger.trace(`process the data..`); 106 | // process the data 107 | this._incomingPacketParser.parse(data); 108 | data = this.conn.read(); 109 | } 110 | }); 111 | 112 | this.on('clientError', this.onError); 113 | this.conn.on('error', this.emit.bind(this, 'clientError')); 114 | 115 | this.conn.on('close', () => { 116 | this.disconnect({ force: false }); 117 | }); 118 | this._eos = eosPromisified(this.conn); 119 | this._eos.catch((err: any) => { 120 | this.emit('error', err); 121 | }); 122 | } 123 | 124 | async handleIncomingPacket(packet: Packet): Promise { 125 | this._clientLogger.trace(`handleIncomingPacket packet.cmd=${packet.cmd}`); 126 | switch (packet.cmd) { 127 | case 'connack': { 128 | const connackCallback = this._inflightPackets.get('connack'); 129 | if (connackCallback) { 130 | this._inflightPackets.delete('connack'); 131 | connackCallback(null, packet as IConnackPacket); 132 | } 133 | break; 134 | } 135 | case 'puback': { 136 | // We should be sending almost every packet into the incoming packet sequencer including publish 137 | // When we add publish, we may need another callback function so the sequencer can tell us when a new publish packet comes in. 138 | // (We need the sequencer to do this because it has to send puback messages and it needs to do the whole QOS-2 thing when packets come in.) 139 | // 140 | // Also, another random thought, when we get suback back from the broker, it will include granted QOS values and we'll need to return those. 141 | this._packetSequencer.handleIncomingPacket((packet as unknown) as sequencer.Packet); 142 | break; 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * connect 149 | * @param options 150 | * @returns 151 | */ 152 | // TODO: Should this be moved up to the index.ts file, or should it live here? 153 | public async connect(): Promise { 154 | logger.trace('sending connect...'); 155 | this.connecting = true; 156 | 157 | const connackPromise = this._awaitConnack(); 158 | const packet: IConnectPacket = { 159 | cmd: 'connect', 160 | clientId: this._options.clientId as string, 161 | protocolVersion: this._options.protocolVersion, 162 | protocolId: this._options.protocolId, 163 | clean: this._options.clean, 164 | keepalive: this._options.keepalive, 165 | username: this._options.username, 166 | password: this._options.password, 167 | will: this._options.will, 168 | properties: this._options.properties, 169 | }; 170 | this._packetSequencer.runSequence(packet) 171 | logger.trace(`running connect sequence...`); 172 | await write(this, packet); 173 | logger.trace('waiting for connack...'); 174 | const connack = await connackPromise; 175 | await this._onConnected(connack); 176 | this.connecting = false; 177 | logger.trace('client connected. returning client...'); 178 | return connack; 179 | } 180 | 181 | /** 182 | * publish - publish to 183 | * Currently only supports QoS 0 Publish 184 | * 185 | * @param {PublishPacket} packet - publish packet 186 | * @returns {Promise} - Promise will be resolved 187 | * when the message has been sent, but not acked. 188 | */ 189 | public async publish(packet: PublishPacket): Promise { 190 | if (!this.connected) { 191 | throw new Error('client must be connected to publish.'); 192 | } 193 | // NumberAllocator's firstVacant method has a Time Complexity of O(1). 194 | // Will return the first vacant number, or null if all numbers are occupied. 195 | // eslint-disable-next-line @typescript-eslint/ban-types 196 | const messageId = this._numberAllocator.alloc(); 197 | if (messageId === null) { 198 | logger.error("All messageId's are allocated."); 199 | this.emit(`error in numberAllocator during publish`); // TODO: this is probably not the event name we want to emit 200 | return; 201 | } 202 | const defaultPublishPacket: IPublishPacket = { 203 | cmd: 'publish', 204 | retain: false, 205 | dup: false, 206 | messageId, 207 | qos: 0, 208 | topic: 'default', 209 | payload: '', 210 | }; 211 | const publishPacket: IPublishPacket = { 212 | ...defaultPublishPacket, 213 | ...packet, 214 | }; 215 | 216 | try { 217 | // TODO: remove this ugly cast 218 | await this._packetSequencer.runSequence('publish', (publishPacket as unknown) as sequencer.Message); 219 | } finally { 220 | this._numberAllocator.free(messageId); 221 | } 222 | } 223 | 224 | private async _destroyClient(force?: boolean) { 225 | this._clientLogger.trace(`destroying client...`); 226 | this.conn.removeAllListeners('error'); 227 | this.conn.removeAllListeners('close'); 228 | this.conn.on('close', () => {}); 229 | this.conn.on('error', () => {}); 230 | 231 | if (force) { 232 | this._clientLogger.trace(`force destroying the underlying connection stream...`); 233 | this.conn.destroy(); 234 | } else { 235 | this._clientLogger.trace(`gracefully ending the underlying connection stream...`); 236 | this.conn.end(() => { 237 | this._clientLogger.trace('END all data has been flushed from stream.'); 238 | }); 239 | // once the stream.end() method has been called, and all the data has been flushed to the underlying system, the 'finish' event is emitted. 240 | this.conn.once('finish', () => { 241 | this._clientLogger.trace('all data has been flushed from stream.'); 242 | }); 243 | } 244 | return this; 245 | } 246 | 247 | public async disconnect({ force, options = {} }: { force?: boolean; options?: any } = {}): Promise { 248 | // if client is already disconnecting, do nothing. 249 | if (this.disconnecting) { 250 | this._clientLogger.trace(`client already disconnecting.`); 251 | return this; 252 | } 253 | 254 | // 255 | this.disconnecting = true; 256 | 257 | this._clientLogger.trace('disconnecting client...'); 258 | const packet: IDisconnectPacket = { 259 | cmd: 'disconnect', 260 | reasonCode: options.reasonCode, 261 | properties: options.properties, 262 | }; 263 | this._clientLogger.trace('writing disconnect...'); 264 | // close the network connection 265 | // ensure NO control packets are sent on the network connection. 266 | // disconnect packet is the final control packet sent from the client to the server. It indicates the client is disconnecting cleanly. 267 | await write(this, packet); 268 | 269 | // once write is done, then switch state to disconnected 270 | this.connected = false; 271 | this.connecting = false; 272 | this._destroyClient(force); 273 | return this; 274 | } 275 | 276 | private async _awaitConnack(): Promise { 277 | return new Promise((resolve, reject) => { 278 | if (this._inflightPackets.has('connack')) { 279 | reject(new Error('connack packet callback already exists')); 280 | return; 281 | } 282 | this._inflightPackets.set('connack', (err, packet) => { 283 | err ? reject(err) : resolve(packet as IConnackPacket); 284 | }); 285 | let connackTimeout: NodeJS.Timeout | null = setTimeout(() => { 286 | this._inflightPackets.delete('connack'); 287 | clearTimeout(connackTimeout as NodeJS.Timeout); 288 | connackTimeout = null; 289 | reject(new Error('connack packet timeout')); 290 | }, this._options.connectTimeout); 291 | }); 292 | } 293 | 294 | private _onConnected(connackPacket: IConnackPacket) { 295 | logger.trace(`updating client state on connected...`); 296 | const rc = connackPacket.returnCode; 297 | if (typeof rc !== 'number') { 298 | throw new Error('Invalid connack packet'); 299 | } 300 | if (rc === 0) { 301 | this.connected = true; 302 | return; 303 | } else if (rc > 0) { 304 | const err: any = new Error('Connection refused: ' + ReasonCodeErrors[rc as keyof typeof ReasonCodeErrors]); 305 | err.code = rc; 306 | this.emit('clientError', err); 307 | throw err; 308 | } 309 | } 310 | 311 | // TODO: follow up on Aedes to see if there is a better way than breaking the Node Streams contract and accessing _writableState 312 | // to make sure that the write callback is cleaned up in case of error. 313 | onError(err?: Error | null | undefined) { 314 | this.emit('error', err); 315 | this.errored = true; 316 | this.conn.removeAllListeners('error'); 317 | this.conn.on('error', () => {}); 318 | // hack to clean up the write callbacks in case of error 319 | this.hackyCleanupWriteCallback(); 320 | this._destroyClient(true); 321 | } 322 | 323 | hackyCleanupWriteCallback() { 324 | // _writableState is not part of the public API for Duplex or Socket, so we have to do some typecasting here to work with it as the stream state. 325 | // See https://github.com/nodejs/node/issues/445 for information on this. 326 | const state = (this.conn as any)._writableState; 327 | if (typeof state.getBuffer !== 'function') { 328 | // See https://github.com/nodejs/node/pull/31165 329 | throw new Error('_writableState.buffer is EOL. _writableState should have getBuffer() as a function.'); 330 | } 331 | const list: any[] = state.getBuffer(); 332 | list.forEach((req) => {req.callback()}); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/connectionFactory/buildWebSocketStream.ts: -------------------------------------------------------------------------------- 1 | import WS from 'ws'; 2 | import { WebSocketOptions } from './interfaces/webSocketOptions.js'; 3 | import { WebSocketStream } from './interfaces/webSocketStream.js'; 4 | 5 | export function buildWebSocketStream(opts: WebSocketOptions): WebSocketStream { 6 | const socket = new WS.WebSocket(opts.url, [opts.websocketSubProtocol]); 7 | const webSocketStream: WebSocketStream = WS.createWebSocketStream(socket, opts.wsOptions); 8 | webSocketStream.url = opts.url; 9 | socket.on('close', () => { 10 | webSocketStream.destroy(); 11 | }); 12 | return webSocketStream; 13 | } 14 | -------------------------------------------------------------------------------- /src/connectionFactory/index.ts: -------------------------------------------------------------------------------- 1 | import net from 'net'; 2 | import { Duplex } from 'stream'; 3 | import tls from 'tls'; 4 | import { URL } from 'url'; 5 | import { ConnectOptions } from '../interface/connectOptions.js'; 6 | import { buildWebSocketStream } from './buildWebSocketStream.js'; 7 | import { WebSocketOptions } from './interfaces/webSocketOptions.js'; 8 | import { logger } from '../util/logger.js'; 9 | 10 | export function connectionFactory(options: ConnectOptions): Duplex { 11 | const brokerUrl: URL = options.brokerUrl as URL; 12 | const tlsOptions = options.tlsOptions; 13 | switch (brokerUrl.protocol) { 14 | case 'tcp:': /* fall through */ 15 | case 'mqtt:': { 16 | const port: number = parseInt(brokerUrl.port) || 1883; 17 | const host: string = brokerUrl.hostname || brokerUrl.host || 'localhost'; 18 | 19 | // logger.info('port %d and host %s', port, host) 20 | return net.createConnection(port, host); 21 | } 22 | case 'tls:': /* fall through */ 23 | case 'mqtts:': { 24 | const port: number = parseInt(brokerUrl.port) || 8883; 25 | const host: string = brokerUrl.hostname || brokerUrl.host || 'localhost'; 26 | const servername: string = brokerUrl.host; 27 | 28 | logger.info(`port ${port} host ${host} servername ${servername}`); 29 | 30 | const connection: tls.TLSSocket = tls.connect({ 31 | port: port, 32 | host: host, 33 | servername: servername, 34 | ...options.tlsOptions, 35 | }); 36 | 37 | const handleTLSerrors = (err: Error) => { 38 | // How can I get verify this error is a tls error? 39 | // TODO: In the old version this was emitted via the client. 40 | // We need to make this better. 41 | if (options.tlsOptions as any['rejectUnauthorized']) { 42 | connection.emit('error', err); 43 | } 44 | 45 | // close this connection to match the behaviour of net 46 | // otherwise all we get is an error from the connection 47 | // and close event doesn't fire. This is a work around 48 | // to enable the reconnect code to work the same as with 49 | // net.createConnection 50 | connection.end(); 51 | }; 52 | 53 | /* eslint no-use-before-define: [2, "nofunc"] */ 54 | connection.on('secureConnect', function () { 55 | if ((tlsOptions as any['rejectUnauthorized']) && !connection.authorized) { 56 | connection.emit('error', new Error('TLS not authorized')); 57 | } else { 58 | connection.removeListener('error', handleTLSerrors); 59 | } 60 | }); 61 | 62 | connection.on('error', handleTLSerrors); 63 | return connection; 64 | } 65 | case 'ws:': { 66 | const url = options.transformWsUrl ? options.transformWsUrl(options.brokerUrl) : (options.brokerUrl as URL); 67 | const websocketSubProtocol = options.protocolId === 'MQIsdp' && options.protocolVersion === 3 ? 'mqttv3.1' : 'mqtt'; 68 | const webSocketOptions: WebSocketOptions = { 69 | url: url, 70 | hostname: url.hostname || 'localhost', 71 | port: url.port || url.protocol === 'wss' ? 443 : 80, 72 | protocol: url.protocol, 73 | protocolId: options.protocolId, 74 | websocketSubProtocol: websocketSubProtocol, 75 | path: url.pathname || '/', 76 | wsOptions: options.wsOptions || {}, 77 | }; 78 | const wsStream = buildWebSocketStream(webSocketOptions); 79 | return wsStream; 80 | } 81 | default: 82 | throw new Error('Unrecognized protocol'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/connectionFactory/interfaces/webSocketOptions.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | export interface WebSocketOptions { 4 | protocolVersion?: number; 5 | browserBufferTimeout?: number; 6 | browserBufferSize?: number; 7 | objectMode?: any; 8 | url: URL; 9 | hostname: string; 10 | protocol: string; 11 | protocolId?: string; 12 | websocketSubProtocol: 'mqttv3.1' | 'mqtt'; 13 | port: number; 14 | path: string; 15 | binary?: boolean; 16 | wsOptions: any; 17 | } 18 | -------------------------------------------------------------------------------- /src/connectionFactory/interfaces/webSocketStream.ts: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'stream'; 2 | import { WebSocketOptions } from './webSocketOptions.js'; 3 | import { URL } from 'url'; 4 | 5 | export interface WebSocketStream extends Duplex { 6 | setReadable?: any; 7 | setWritable?: any; 8 | socket?: WebSocket; 9 | opts?: WebSocketOptions; 10 | websocketSubProtocol?: any; 11 | url?: URL; 12 | } 13 | -------------------------------------------------------------------------------- /src/defaultClientId.ts: -------------------------------------------------------------------------------- 1 | export function defaultId() { 2 | return 'mqttjs_' + Math.random().toString(16).substr(2, 8); 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015-2015 MQTT.js contributors. 3 | * Copyright (c) 2011-2014 Adam Rudd. 4 | * 5 | * See LICENSE for more information 6 | */ 7 | 8 | import { MqttClient } from './client.js'; 9 | import { ConnectOptions } from './interface/connectOptions.js'; 10 | import { protocols } from './util/constants.js'; 11 | import { logger } from './util/logger.js'; 12 | 13 | import { URL } from 'url'; 14 | 15 | /** 16 | * connect() 17 | * Connect will: 18 | * 1) Validate the options provided by the user. 19 | * 2) Instantiate a new client. 20 | * 3) Return the client to the user. 21 | */ 22 | async function connect(options: ConnectOptions) { 23 | logger.info(`validating options...`); 24 | if (typeof options.brokerUrl === 'string') { 25 | options.brokerUrl = new URL(options.brokerUrl); 26 | } 27 | 28 | if (!options?.brokerUrl?.protocol) { 29 | throw new Error( 30 | `Missing protocol. \ 31 | To provide a protocol, you have two options:\ 32 | - Format the brokerUrl with a protocol, for example: 'mqtt://test.mosquitto.org'. 33 | - Pass in the protocol via the protocol option.` 34 | ); 35 | } 36 | 37 | const validationErr: Error | undefined = _validateProtocol(options); 38 | if (validationErr) { 39 | throw validationErr; 40 | } 41 | 42 | logger.trace('creating new client...'); 43 | const client = new MqttClient(options); 44 | const connackPacket = await client.connect(); 45 | logger.trace(`connack packet: ${JSON.stringify(connackPacket)}`); 46 | logger.trace('returning client...'); 47 | return client; 48 | } 49 | 50 | function _validateProtocol(opts: ConnectOptions): Error | undefined { 51 | logger.info(`validating protocol options...`); 52 | if (opts.tlsOptions && 'cert' in opts.tlsOptions && 'key' in opts.tlsOptions) { 53 | const urlProtocol = (opts.brokerUrl as URL).protocol; 54 | if (urlProtocol) { 55 | if (protocols.secure.indexOf(urlProtocol) === -1) { 56 | const protocolError: Error = formatSecureProtocolError(urlProtocol); 57 | return protocolError; 58 | } 59 | } else { 60 | // A cert and key was provided, however no protocol was specified, so we will throw an error. 61 | // TODO: Git Blame on this line. I don't understand the error message at all. 62 | return new Error('Missing secure protocol key'); 63 | } 64 | } 65 | return undefined; 66 | } 67 | 68 | function formatSecureProtocolError(protocol: string): Error { 69 | logger.info('secure protocol error! formatting secure protocol error... '); 70 | let secureProtocol: string; 71 | switch (protocol) { 72 | case 'mqtt': 73 | secureProtocol = 'mqtts'; 74 | break; 75 | case 'ws': 76 | secureProtocol = 'wss'; 77 | break; 78 | default: 79 | return new Error('Unknown protocol for secure connection: "' + protocol + '"!'); 80 | } 81 | return new Error( 82 | `user provided cert and key , but protocol ${protocol} is insecure. 83 | Use ${secureProtocol} instead.` 84 | ); 85 | } 86 | 87 | export { connect }; 88 | -------------------------------------------------------------------------------- /src/interface/clientOptions.ts: -------------------------------------------------------------------------------- 1 | export interface ClientOptions { 2 | /** 3 | * MQTT protocol version to use. Use 4 for vMQTT 3.1.1, and 5 for MQTT v5.0 4 | * Default: 5 5 | */ 6 | protocolVersion?: 4 | 5; 7 | } -------------------------------------------------------------------------------- /src/interface/connectOptions.ts: -------------------------------------------------------------------------------- 1 | import { QoS, UserProperties } from 'mqtt-packet'; 2 | import { Duplex } from 'stream'; 3 | import { TlsOptions } from 'tls'; 4 | import { WsOptions } from './wsOptions.js'; 5 | 6 | export interface ConnectOptions { 7 | objectMode?: any; 8 | autoUseTopicAlias?: any; 9 | autoAssignTopicAlias?: any; 10 | topicAliasMaximum?: number; 11 | queueLimit?: number; 12 | cmd?: 'connect'; 13 | clientId?: string; 14 | protocolVersion?: 4 | 5 | 3; 15 | protocolId?: 'MQTT' | 'MQIsdp'; 16 | clean?: boolean; 17 | keepalive?: number; 18 | username?: string; 19 | password?: Buffer; 20 | will?: { 21 | topic: string; 22 | payload: Buffer; 23 | qos?: QoS; 24 | retain?: boolean; 25 | properties?: { 26 | willDelayInterval?: number; 27 | payloadFormatIndicator?: boolean; 28 | messageExpiryInterval?: number; 29 | contentType?: string; 30 | responseTopic?: string; 31 | correlationData?: Buffer; 32 | userProperties?: UserProperties; 33 | }; 34 | }; 35 | properties?: { 36 | sessionExpiryInterval?: number; 37 | receiveMaximum?: number; 38 | maximumPacketSize?: number; 39 | topicAliasMaximum?: number; 40 | requestResponseInformation?: boolean; 41 | requestProblemInformation?: boolean; 42 | userProperties?: UserProperties; 43 | authenticationMethod?: string; 44 | authenticationData?: Buffer; 45 | }; 46 | brokerUrl?: URL; 47 | wsOptions?: { [key: string]: WsOptions | unknown }; 48 | tlsOptions?: { [key: string]: TlsOptions | unknown }; 49 | reschedulePings?: any; 50 | reconnectPeriod?: any; 51 | connectTimeout?: any; 52 | incomingStore?: any; 53 | outgoingStore?: any; 54 | queueQoSZero?: any; 55 | customHandleAcks?: any; 56 | authPacket?: any; 57 | transformWsUrl?: (options: any) => URL; 58 | resubscribe?: boolean; 59 | customStreamFactory?: (options: ConnectOptions) => Duplex; 60 | } 61 | -------------------------------------------------------------------------------- /src/interface/wsOptions.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http'; 2 | import { Server as HttpsServer } from 'https'; 3 | 4 | export type WsOptions = { 5 | backlog: number; 6 | clientTracking: boolean; 7 | handleProtocols: () => unknown; 8 | host: string; 9 | maxPayload: number; 10 | noServer: boolean; 11 | path: string; 12 | perMessageDeflate: boolean | { [x: string]: unknown }; 13 | port: number; 14 | server: Server | HttpsServer; 15 | skipUTF8Validation: boolean; 16 | verifyClient: () => unknown; 17 | } & { 18 | [prop: string]: string; 19 | }; 20 | -------------------------------------------------------------------------------- /src/sequencer.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './util/logger.js'; 2 | import { IConnectPacket, IConnackPacket, Packet, IDisconnectPacket } from 'mqtt-packet'; 3 | import { NumberAllocator } from 'number-allocator' 4 | import { ReturnCodes } from './util/returnCodes.js'; 5 | import { ReasonCodes } from './util/reasonCodes.js'; 6 | import { ClientOptions } from './interface/clientOptions.js' 7 | 8 | type SequenceId = number | 'connect' | 'pingreq' | 'disconnect'; 9 | type InFlightSequenceMap = Map 10 | type SendPacketFunction = (packet: Packet) => Promise 11 | type DoneFunction = (finalPacket: Packet | null, err?: Error) => void 12 | type SequenceMachineConstructor = new ( 13 | initialPacket: Packet, 14 | sendPacketFunction: SendPacketFunction, 15 | doneFunction: DoneFunction 16 | ) => SequenceMachine 17 | 18 | const notImplementedErrorMessage = 'Not implemented'; 19 | const operationStartedErrorMessage = 'Operation can only be started once.'; 20 | 21 | export class MqttPacketSequencer { 22 | private _sendPacketFunction: (packet: Packet) => Promise; 23 | private _inFlightSequences: InFlightSequenceMap = new Map(); 24 | private _numberAllocator: NumberAllocator = new NumberAllocator(1, 65535); 25 | private _clientOptions: ClientOptions; 26 | 27 | constructor(clientOptions: ClientOptions, sendPacketFunction: SendPacketFunction) { 28 | this._clientOptions = clientOptions; 29 | this._sendPacketFunction = sendPacketFunction; 30 | } 31 | 32 | async runSequence(initialPacket: Packet) { 33 | let sequenceMachineConstructor: SequenceMachineConstructor; 34 | let sequenceId: SequenceId; 35 | 36 | switch (initialPacket.cmd) { 37 | /* FALLTHROUGH */ 38 | case 'pingreq': 39 | case 'subscribe': 40 | case 'unsubscribe': 41 | case 'publish': 42 | throw new Error(notImplementedErrorMessage); 43 | 44 | case 'connect': 45 | // TODO: enhanced authentication 46 | sequenceId = 'connect'; 47 | sequenceMachineConstructor = BasicConnect as SequenceMachineConstructor; 48 | break; 49 | case 'disconnect': 50 | sequenceId = 'disconnect'; 51 | sequenceMachineConstructor = Disconnect as SequenceMachineConstructor; 52 | break; 53 | 54 | default: 55 | throw new Error('Invalid initial control packet type'); 56 | } 57 | if (this._inFlightSequences.has(sequenceId)) { 58 | throw new Error('Sequence with matching ID already in flight'); 59 | } 60 | 61 | try { 62 | await new Promise((resolve, reject) => { 63 | // You're only going to have an inflight sequence if you are waiting for a response from the server. So every case except QoS 0 Publish, and Disconnect. 64 | this._inFlightSequences.set(sequenceId, new sequenceMachineConstructor( 65 | initialPacket, 66 | this._sendPacketFunction, 67 | (err?: Error) => { err ? reject(err) : resolve() } 68 | )); 69 | }); 70 | } finally { 71 | this._inFlightSequences.delete(sequenceId); 72 | } 73 | } 74 | 75 | handleIncomingPacket(packet: Packet) { 76 | let sequenceMachine: SequenceMachine | undefined; 77 | switch (packet.cmd) { 78 | /* FALLTHROUGH */ 79 | case 'auth': 80 | case 'disconnect': 81 | case 'pingresp': 82 | case 'puback': 83 | case 'pubcomp': 84 | case 'publish': 85 | case 'pubrel': 86 | case 'pubrec': 87 | case 'suback': 88 | case 'unsuback': 89 | throw new Error(notImplementedErrorMessage); 90 | 91 | case 'connack': 92 | sequenceMachine = this._inFlightSequences.get('connect'); 93 | break; 94 | 95 | default: 96 | throw new Error('Invalid incoming control packet type'); 97 | } 98 | if (!sequenceMachine) { 99 | throw new Error('No matching sequence machine for incoming packet'); 100 | } 101 | sequenceMachine.handleIncomingPacket(packet); 102 | } 103 | } 104 | 105 | abstract class SequenceMachine { 106 | protected _sendPacketFunction: SendPacketFunction; 107 | protected _done: DoneFunction; 108 | protected _initialPacket: Packet; 109 | protected _clientOptions: ClientOptions; 110 | 111 | constructor(initialPacket: Packet, clientOptions: ClientOptions, sendPacketFunction: SendPacketFunction, done: DoneFunction) { 112 | this._initialPacket = initialPacket; 113 | this._sendPacketFunction = sendPacketFunction; 114 | this._done = done; 115 | this._clientOptions = clientOptions 116 | } 117 | 118 | abstract start(): void; 119 | abstract handleIncomingPacket(packet: Packet): void; 120 | abstract cancel(error?: Error): void; 121 | } 122 | 123 | enum BasicConnectState { 124 | New, 125 | AwaitingConnack, 126 | Done, 127 | Cancelled, 128 | Failed 129 | } 130 | 131 | class BasicConnect extends SequenceMachine { 132 | /**TODO: 133 | * - handle keepalive 134 | * - handle session present, expiry 135 | * - max QoS 136 | * - max packet size 137 | * - assigned client id 138 | * - retained messages 139 | * - topic alias max 140 | * - reason string 141 | * - features available: wildcard subscription, subscription identifier, shared subscriptions 142 | * - response information 143 | */ 144 | private _state = BasicConnectState.New; 145 | 146 | constructor(initialPacket: IConnectPacket, clientOptions: ClientOptions, sendPacketFunction: SendPacketFunction, done: DoneFunction) { 147 | super(initialPacket, clientOptions, sendPacketFunction, done); 148 | if (initialPacket.cmd !== 'connect') { 149 | throw new Error('BasicConnect must have a connect packet as the initial packet.'); 150 | } 151 | if (initialPacket.protocolId !== 'MQTT') { 152 | throw new Error('BasicConnect must have a MQTT protocol ID.'); 153 | } 154 | if (initialPacket.protocolVersion !== clientOptions.protocolVersion) { 155 | throw new Error('Protocol version in connect packet must match client options.'); 156 | } 157 | if (!initialPacket.clean) { 158 | throw new Error('Connecting with an existing session is not supported yet.'); 159 | } 160 | } 161 | 162 | start() { 163 | if (this._state !== BasicConnectState.New) { 164 | throw new Error(operationStartedErrorMessage); 165 | } 166 | this._sendConnect(); 167 | } 168 | 169 | handleIncomingPacket(packet: IConnackPacket) { 170 | if (packet.cmd !== 'connack') { 171 | this._finishWithFailure(new Error(`Expected connack, but received ${packet.cmd}`)) 172 | return; 173 | } 174 | this._clientOptions.protocolVersion === 4 ? this._handleMqtt4Connack(packet) : this._handleMqtt5Connack(packet); 175 | } 176 | 177 | cancel(error?: Error) { 178 | this._state = error ? BasicConnectState.Failed : BasicConnectState.Cancelled; 179 | this._done(null, error); 180 | } 181 | 182 | private _finishWithFailure(error: Error) { 183 | this._state = BasicConnectState.Failed; 184 | this._done(null, error); 185 | } 186 | 187 | private _finishWithSuccess(finalPacket: IConnackPacket) { 188 | this._state = BasicConnectState.Done; 189 | this._done(finalPacket); 190 | } 191 | 192 | private _sendConnect() { 193 | /* TODO: Allow CONNACK timeout to be configurable */ 194 | this._state = BasicConnectState.AwaitingConnack; 195 | this._sendPacketFunction(this._initialPacket) 196 | .then(() => setTimeout(this._finishWithFailure.bind(this), 60 * 1000, new Error('Timed out waiting for CONNACK'))) 197 | .catch(this._finishWithFailure.bind(this)); 198 | } 199 | 200 | private _handleMqtt4Connack(packet: IConnackPacket) { 201 | /* TODO: check server sent a valid packet */ 202 | const returnCode = packet.returnCode as keyof typeof ReturnCodes.connack 203 | const returnCodeMessage = ReturnCodes.connack[returnCode]; 204 | if (returnCodeMessage === undefined) { 205 | this._finishWithFailure(new Error('Server sent invalid CONNACK return code')); 206 | return; 207 | } 208 | if (returnCode !== 0) { 209 | this._finishWithFailure(new Error(`Server returned error: ${returnCodeMessage}`)); 210 | return; 211 | } 212 | this._finishWithSuccess(packet); 213 | } 214 | 215 | private _handleMqtt5Connack(packet: IConnackPacket) { 216 | /* TODO: check server sent a valid packet */ 217 | const reasonCode = packet.reasonCode as keyof typeof ReasonCodes.connack 218 | const reasonCodeMessage = ReasonCodes.connack[reasonCode]; 219 | if (reasonCodeMessage === undefined) { 220 | this._finishWithFailure(new Error('Server sent invalid CONNACK reason code')); 221 | return; 222 | } 223 | if (reasonCode >= 0x80) { 224 | this._finishWithFailure(new Error(`Server returned error: ${reasonCodeMessage}`)); 225 | return; 226 | } 227 | this._finishWithSuccess(packet); 228 | } 229 | } 230 | 231 | enum ClientDisconnectState { 232 | New, 233 | Done, 234 | Failed 235 | } 236 | 237 | class ClientDisconnect extends SequenceMachine { 238 | private _state = ClientDisconnectState.New; 239 | 240 | constructor(initialPacket: IDisconnectPacket, clientOptions: ClientOptions, sendPacketFunction: SendPacketFunction, done: DoneFunction) { 241 | super(initialPacket, clientOptions, sendPacketFunction, done); 242 | if (initialPacket.cmd !== 'disconnect') { 243 | throw new Error('ClientDisconnect must have a disconnect packet as the initial packet.'); 244 | } 245 | /* err if MQTT v4 and reason code */ 246 | /* err if MQTT v4 and properties */ 247 | /* err if MQTT v5 and invalid client reason code */ 248 | } 249 | 250 | start() { 251 | if (this._state !== ClientDisconnectState.New) { 252 | throw new Error(operationStartedErrorMessage); 253 | } 254 | } 255 | } -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { Packet } from 'mqtt-packet'; 2 | import { Readable } from 'stream'; 3 | 4 | const streamsOpts = { objectMode: true }; 5 | const defaultStoreOptions = { 6 | clean: true, 7 | }; 8 | 9 | /** 10 | * In-memory implementation of the message store 11 | * This can actually be saved into files. 12 | * 13 | * @param {Object} [options] - store options 14 | */ 15 | export class Store { 16 | private _inflights: any; 17 | options: any; 18 | 19 | constructor(options: any = {}) { 20 | this.options = { ...options, defaultStoreOptions }; 21 | this._inflights = new Map(); 22 | } 23 | 24 | /** 25 | * Adds a packet to the store, a packet is 26 | * anything that has a messageId property. 27 | * 28 | */ 29 | async put(packet: Packet): Promise { 30 | this._inflights.set(packet.messageId, packet); 31 | return this; 32 | } 33 | 34 | /** 35 | * Creates a stream with all the packets in the store 36 | * 37 | */ 38 | async createStream() { 39 | const stream = new Readable(streamsOpts); 40 | let destroyed = false; 41 | const values: any[] = []; 42 | let i = 0; 43 | 44 | this._inflights.forEach((value: any) => { 45 | values.push(value); 46 | }); 47 | 48 | stream._read = function () { 49 | if (!destroyed && i < values.length) { 50 | this.push(values[i++]); 51 | } else { 52 | this.push(null); 53 | } 54 | }; 55 | 56 | stream.destroy = function (_error?: Error | undefined): Readable { 57 | if (!destroyed) { 58 | destroyed = true; 59 | setTimeout(() => { 60 | this.emit('close'); 61 | }, 0); 62 | } 63 | return stream; 64 | }; 65 | 66 | return stream; 67 | } 68 | 69 | /** 70 | * deletes a packet from the store. 71 | */ 72 | async del(packet: Packet): Promise { 73 | packet = this._inflights.get(packet.messageId); 74 | if (packet) { 75 | this._inflights.delete(packet.messageId); 76 | return packet; 77 | } else { 78 | throw new Error('missing packet'); 79 | } 80 | } 81 | 82 | /** 83 | * get a packet from the store. 84 | */ 85 | async get(packet: Packet): Promise { 86 | packet = this._inflights.get(packet.messageId); 87 | if (packet) { 88 | return packet; 89 | } else { 90 | throw new Error('missing packet'); 91 | } 92 | } 93 | 94 | /** 95 | * Close the store 96 | */ 97 | async close() { 98 | if (this.options.clean) { 99 | this._inflights = null; 100 | } 101 | return; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/util/constants.ts: -------------------------------------------------------------------------------- 1 | import { ConnectOptions } from '../interface/connectOptions.js'; 2 | 3 | export const defaultConnectOptions: ConnectOptions = { 4 | keepalive: 60, 5 | reschedulePings: true, 6 | protocolId: 'MQTT', 7 | protocolVersion: 4, 8 | reconnectPeriod: 1000, 9 | connectTimeout: 30 * 1000, 10 | clean: true, 11 | resubscribe: true, 12 | }; 13 | 14 | export const protocols = { 15 | all: ['mqtt', 'mqtts', 'ws', 'wss'], 16 | secure: ['mqtts', 'ws'], 17 | insecure: ['mqtt', 'wss'], 18 | }; 19 | -------------------------------------------------------------------------------- /src/util/defaultClientId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates client id with random 8 digit long base 16 value 3 | * @returns clientId 4 | */ 5 | export function defaultClientId(): string { 6 | return 'mqttjs_' + Math.random().toString(16).substr(2, 8); 7 | } 8 | -------------------------------------------------------------------------------- /src/util/error.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino'; 2 | 3 | import { logger } from './logger.js'; 4 | 5 | export const handle = pino.final(logger, (err, finalLogger) => { 6 | finalLogger.fatal(err); 7 | process.exitCode = 1; 8 | process.kill(process.pid, 'SIGTERM'); 9 | }); 10 | -------------------------------------------------------------------------------- /src/util/logger.ts: -------------------------------------------------------------------------------- 1 | import { pino } from 'pino'; 2 | 3 | export const logger = pino({ 4 | name: 'mqtt', 5 | level: 'trace', 6 | customLevels: { 7 | test: 35, 8 | }, 9 | transport: { 10 | target: 'pino-pretty', 11 | options: { 12 | colorize: true, 13 | ignore: 'time,hostname', 14 | translateTime: 'yyy-dd-mm, h:MM:ss TT', 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/util/reasonCodes.ts: -------------------------------------------------------------------------------- 1 | // Reason Codes are part of the MQTT 5 specification 2 | export const ReasonCodes = { 3 | 'connack': { 4 | 0x00: 'Success', 5 | 0x80: 'Unspecified error', 6 | 0x81: 'Malformed Packet', 7 | 0x82: 'Protocol Error', 8 | 0x83: 'Implementation specific error', 9 | 0x84: 'Unsupported Protocol Version', 10 | 0x85: 'Client Identifier not valid', 11 | 0x86: 'Bad User Name or Password', 12 | 0x87: 'Not authorized', 13 | 0x88: 'Server unavailable', 14 | 0x89: 'Server busy', 15 | 0x8A: 'Banned', 16 | 0x8C: 'Bad authentication method', 17 | 0x90: 'Topic Name invalid', 18 | 0x95: 'Packet too large', 19 | 0x97: 'Quota exceeded', 20 | 0x99: 'Payload format invalid', 21 | 0x9A: 'Retain not supported', 22 | 0x9B: 'QoS not supported', 23 | 0x9C: 'Use another server', 24 | 0x9D: 'Server moved', 25 | 0x9F: 'Connection rate exceeded', 26 | }, 27 | 'puback': { 28 | 0x00: 'Success', 29 | 0x10: 'No matching subscribers', 30 | 0x80: 'Unspecified error', 31 | 0x83: 'Implementation specific error', 32 | 0x87: 'Not authorized', 33 | 0x90: 'Topic Name invalid', 34 | 0x91: 'Packet identifier in use', 35 | 0x97: 'Quota exceeded', 36 | 0x99: 'Payload format invalid', 37 | }, 38 | 'pubrec': { 39 | 0x00: 'Success', 40 | 0x10: 'No matching subscribers.', 41 | 0x80: 'Unspecified error', 42 | 0x83: 'Implementation specific error', 43 | 0x87: 'Not authorized', 44 | 0x90: 'Topic Name invalid', 45 | 0x91: 'Packet Identifier in use', 46 | 0x97: 'Quota exceeded', 47 | 0x99: 'Payload format invalid' 48 | }, 49 | 'pubrel': { 50 | 0x00: 'Success', 51 | 0x92: 'Packet Identifier not found' 52 | }, 53 | 'pubcomp': { 54 | 0x00: 'Success', 55 | 0x92: 'Packet Identifier not found' 56 | }, 57 | 'suback': { 58 | 0x00: 'Granted QoS 0', 59 | 0x01: 'Granted QoS 1', 60 | 0x02: 'Granted QoS 2', 61 | 0x80: 'Unspecified error', 62 | 0x83: 'Implementation specific error', 63 | 0x87: 'Not authorized', 64 | 0x8F: 'Topic Filter invalid', 65 | 0x91: 'Packet Identifier in use', 66 | 0x97: 'Quota exceeded', 67 | 0x9E: 'Shared Subscriptions not supported', 68 | 0xA1: 'Subscription Identifiers not supported', 69 | 0xA2: 'Wildcard Subscriptions not supported' 70 | }, 71 | 'unsuback': { 72 | 0x00: 'Success', 73 | 0x11: 'No subscription existed', 74 | 0x80: 'Unspecified error', 75 | 0x83: 'Implementation specific error', 76 | 0x87: 'Not authorized', 77 | 0x8F: 'Topic Filter invalid', 78 | 0x91: 'Packet Identifier in use' 79 | }, 80 | 'disconnect': { 81 | 0x00: 'Normal disconnection', 82 | /* This reason code can only be received by a server */ 83 | // 0x04: 'Disconnect with Will Message', 84 | 0x80: 'Unspecified error', 85 | 0x81: 'Malformed Packet', 86 | 0x82: 'Protocol Error', 87 | 0x83: 'Implementation specific error', 88 | 0x87: 'Not authorized', 89 | 0x89: 'Server busy', 90 | 0x8B: 'Server shutting down', 91 | 0x8D: 'Keep Alive timeout', 92 | 0x8E: 'Session taken over', 93 | 0x8F: 'Topic Filter invalid', 94 | 0x90: 'Topic Name invalid', 95 | 0x93: 'Receive Maximum exceeded', 96 | 0x94: 'Topic Alias invalid', 97 | 0x95: 'Packet too large', 98 | 0x96: 'Message rate too high', 99 | 0x97: 'Quota exceeded', 100 | 0x98: 'Administrative action', 101 | 0x99: 'Payload format invalid', 102 | 0x9A: 'Retain not supported', 103 | 0x9B: 'QoS not supported', 104 | 0x9C: 'Use another server', 105 | 0x9D: 'Server moved', 106 | 0x9E: 'Shared Subscriptions not supported', 107 | 0x9F: 'Connection rate exceeded', 108 | 0xA0: 'Maximum connect time', 109 | 0xA1: 'Subscription Identifiers not supported', 110 | 0xA2: 'Wildcard Subscriptions not supported' 111 | }, 112 | 'auth': { 113 | 0x00: 'Success', 114 | 0x18: 'Continue authentication', 115 | /* This reason code can only be received by a server */ 116 | // 0x19: 'Re-authenticate' 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/util/returnCodes.ts: -------------------------------------------------------------------------------- 1 | // Return Codes are part of the MQTT 3.1.1 specification 2 | export const ReturnCodes = { 3 | 'connack': { 4 | 0x00: 'Connection Accepted', 5 | 0x01: 'Connection Refused: unacceptable protocol version', 6 | 0x02: 'Connection Refused: identifier rejected', 7 | 0x03: 'Connection Refused: server unavailable', 8 | 0x04: 'Connection Refused: bad user name or password', 9 | 0x05: 'Connection Refused: not authorized', 10 | }, 11 | 'suback': { 12 | 0x00: 'Success: Maximum QoS 0', 13 | 0x01: 'Success: Maximum QoS 1', 14 | 0x02: 'Success: Maximum QoS 2', 15 | 0x80: 'Failure' 16 | } 17 | } -------------------------------------------------------------------------------- /src/util/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(time?: number): { 2 | promise: Promise; 3 | cancel: () => void; 4 | } { 5 | let outerReject: (reason?: any) => void; 6 | let timer: number; 7 | const promise = new Promise((resolve, reject) => { 8 | outerReject = reject; 9 | timer = setTimeout(resolve, time); 10 | }); 11 | const cancel = () => { 12 | clearTimeout(timer); 13 | outerReject(new Error('sleep cancelled')); 14 | }; 15 | return { promise, cancel }; 16 | } 17 | 18 | /** 19 | {promise: Promise, cancel: () => void} 20 | */ 21 | -------------------------------------------------------------------------------- /src/util/validateTopic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validate a topic to see if it's valid or not. 3 | * Topics can support Multi-level wildcards (‘#’ U+0023) and single-level wildcards (‘+’ U+002B). 4 | * 5 | * The multi-level wildcard character MUST be specified either on its own or following a topic level separator. 6 | * In either case it MUST be the last character specified in the Topic Filter. 7 | * 8 | * The single-level wildcard character MUST occupy an entire level of the filter. 9 | * For example: "+" is valid, "+/tennis/#" is valid, "sport+" is not valid. 10 | * 11 | * Topics beginning with $: The Server MUST NOT match Topic Filters starting with a wildcard character with Topic Names 12 | * 13 | * Topic names and topic filters must not include the null character (Unicode U+0000) 14 | * 15 | * Topic names and Topic Filters are UTF-8 encoded strings; they must not encode to more than 65,535 bytes. 16 | * 17 | * @param {String} topic - A topic 18 | * @returns {Boolean} If the topic is valid, returns true. Otherwise, returns false. 19 | */ 20 | export function validateTopic(topic: string) { 21 | // Topic must be at least 1 character. 22 | if (topic.length === 0) { 23 | return false; 24 | } 25 | const levels: string[] = topic.split('/'); 26 | 27 | for (const [i, level] of levels.entries()) { 28 | // If SLWC, MUST occupy entire level. 29 | if (level === '+') { 30 | continue; 31 | } 32 | 33 | if (level === '#') { 34 | // Validate MLWC at end of topic filter. 35 | return i === levels.length - 1; 36 | } 37 | 38 | // Level must not contain more than one MLWC or SLWC character. 39 | if (level.includes('+') || level.includes('#')) { 40 | return false; 41 | } 42 | } 43 | return true; 44 | } 45 | 46 | /** 47 | * Validate an array of topics to see if any of them is valid or not 48 | * @param {Array} topics - Array of topics 49 | * @returns {String} If the topics is valid, returns null. Otherwise, returns the invalid one 50 | */ 51 | export function validateTopics(topics: Array) { 52 | if (topics.length === 0) { 53 | return 'empty_topic_list'; 54 | } 55 | for (let i = 0; i < topics.length; i++) { 56 | if (!validateTopic(topics[i] as string)) { 57 | return topics[i]; 58 | } 59 | } 60 | return null; 61 | } 62 | -------------------------------------------------------------------------------- /src/write.ts: -------------------------------------------------------------------------------- 1 | import * as mqtt from 'mqtt-packet'; 2 | import { MqttClient } from './client.js'; 3 | import { logger } from './util/logger.js'; 4 | 5 | export async function write(client: MqttClient, packet: mqtt.Packet): Promise { 6 | /* TODO: Enforce maximum packet size */ 7 | logger.trace(`writing packet: ${JSON.stringify(packet)}`); 8 | if (!client.connected && !client.connecting) throw new Error('connection closed'); 9 | 10 | /** 11 | * If writeToStream returns true, we can immediately continue. Otherwise, 12 | * either we need to wait for the 'drain' event or the client has errored. 13 | */ 14 | if (mqtt.writeToStream(packet, client.conn)) return; 15 | // if we get an error while writing to the packet stream in mqtt-packet v8, then the stream will synchronously be set to 'destroyed' immediately, so we can 16 | // check for errors by checking if the connection is destroyed after writing to stream. 17 | if (client.conn.destroyed) throw new Error('stream destroyed while attempting to write'); 18 | 19 | 20 | 21 | /** TODO: if there is an issue, write could potentially stall forever 22 | * We should look into if we should have a timeout here or if the timeout should be 23 | */ 24 | /** 25 | * TODO: Need to make sure that this promise settles if the client errors 26 | * before the 'drain' event is emitted. Aedes does a weird hack to make it 27 | * work, but it's not clear if it's the right thing to do. See Aedes: 28 | * https://github.com/moscajs/aedes/blob/39ccdb554d9e32113216e5f7180d3297314e5e12/lib/client.js#L193-L196 29 | */ 30 | if (!client.errored) return new Promise((resolve) => client.conn.once('drain', resolve)); 31 | } 32 | -------------------------------------------------------------------------------- /test/test_connect.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { connect } from '../dist/index.js'; 3 | import { logger } from '../dist/util/logger.js'; 4 | import { serverFactoryMacro, cleanupAfterAllTestsMacro, cleanupBetweenTestsMacro } from './util/testing_server_factory.js'; 5 | 6 | const port = 1884; 7 | 8 | /* ===================== BEGIN before/beforeEach HOOKS ===================== */ 9 | test.before('set up aedes broker', serverFactoryMacro, port); 10 | /* ====================== END before/beforeEach HOOKS ====================== */ 11 | 12 | /* ============================== BEGIN TESTS ============================== */ 13 | /* NOTE: Use unique clientId to de-conflict tests since they run in parallel */ 14 | test('should send a CONNECT packet with the correct default parameters', async (t) => { 15 | t.plan(7); 16 | 17 | const clientConnectedPromise = new Promise((resolve) => { 18 | const connectReceivedListener = (packet) => { 19 | /* Ensure default mqttjs client ID is used */ 20 | if (!packet.clientId.startsWith('mqttjs_')) return; 21 | 22 | t.context.broker.removeListener('connectReceived', connectReceivedListener); 23 | 24 | /* Ensure default options are used in connect packet */ 25 | t.falsy(packet.will); 26 | t.falsy(packet.username); 27 | t.falsy(packet.password); 28 | t.true(packet.clean); 29 | t.is(packet.keepalive, 60); 30 | t.is(packet.protocolVersion, 4); 31 | t.is(packet.protocolId, 'MQTT'); 32 | 33 | resolve(); 34 | }; 35 | t.context.broker.on('connectReceived', connectReceivedListener); 36 | }); 37 | t.context.client = await connect({ brokerUrl: `mqtt://localhost:${port}` }); 38 | await clientConnectedPromise; 39 | }); 40 | 41 | test.todo('should send a CONNECT packet with a user-provided client ID'); 42 | test.todo('should send a CONNECT packet with a user-provided protocol level'); 43 | test.todo('should send a DISCONNECT packet when closing client'); 44 | test.todo('can send a PINGREQ at any time'); 45 | test.todo( 46 | 'should close the network connection to the server if client does not receive PINGRESP within a reasonable amount of time' 47 | ); 48 | test.todo('a keepalive of zero (0) has the effect of turning off the keepalive mechanism'); 49 | test.todo('user can specify value of keepalive'); 50 | test.todo('maximum value of keepalive is 18 hours, 12 minutes and 15 seconds'); 51 | test.todo('the client identifier (clientId) must be present'); 52 | test.todo( 53 | 'a clientId must be between 1 and 23 UTF-8 encoded bytest in length and contain only the characters "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"' 54 | ); 55 | test.todo('user can use convenience method to generate a random clientId'); 56 | test.todo('user cannot use random clientId if cleanSession is set to 0'); 57 | test.todo('the first packet sent from the server MUST be a CONNACK packet'); 58 | test.todo('handle return codes 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 on CONNACK'); 59 | test.todo('can send PUBLISH packet'); 60 | test.todo('can send SUBSCRIBE packet'); 61 | test.todo('must have same packet identifier on SUBSCRIBE and SUBACK packet'); 62 | 63 | /* TODO: Stub out more tests */ 64 | 65 | /* =============================== END TESTS =============================== */ 66 | 67 | /* ====================== BEGIN after/afterEach HOOKS ====================== */ 68 | test.afterEach.always(cleanupBetweenTestsMacro); 69 | 70 | test.after.always(cleanupAfterAllTestsMacro); 71 | /* ======================= END after/afterEach HOOKS ======================= */ 72 | -------------------------------------------------------------------------------- /test/test_disconnect.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { connect } from '../dist/index.js'; 3 | import { logger } from '../dist/util/logger.js'; 4 | import { cleanupAfterAllTestsMacro, cleanupBetweenTestsMacro, serverFactoryMacro } from './util/testing_server_factory.js'; 5 | 6 | const port = 1885; 7 | /* ===================== BEGIN before/beforeEach HOOKS ===================== */ 8 | test.before('set up aedes broker', serverFactoryMacro, port); 9 | /* ====================== END before/beforeEach HOOKS ====================== */ 10 | 11 | /* ============================== BEGIN TESTS ============================== */ 12 | /* NOTE: Use unique clientId to de-conflict tests since they run in parallel */ 13 | 14 | test('should disconnect and clean up connection stream', async (t) => { 15 | const clientDisconnectListenerPromise = new Promise((resolve) => { 16 | const clientDisconnectListener = async (client) => { 17 | logger.test(`client ${client.id} is disconnected.`); 18 | t.context.broker.removeListener('clientDisconnect', clientDisconnectListener); 19 | resolve(client); 20 | }; 21 | t.context.broker.on('clientDisconnect', clientDisconnectListener); 22 | }); 23 | const connectReceivedListener = async (packet) => { 24 | logger.test(`connectReceivedListener called with packet ${packet}`); 25 | t.context.broker.removeListener('connectReceived', connectReceivedListener); 26 | }; 27 | t.context.broker.on('connectReceived', connectReceivedListener); 28 | 29 | const client = await connect({ brokerUrl: `mqtt://localhost:${port}` }); 30 | logger.test(`client connected. disconnecting...`); 31 | await client.disconnect(); 32 | const cFromBroker = await clientDisconnectListenerPromise; 33 | // TODO: Should ._options.clientId be reformatted as .id? 34 | t.assert(client._options.clientId === cFromBroker.id); 35 | }); 36 | test.todo('should mark the client as disconnected'); 37 | test.todo('should stop ping timer if stream closes'); 38 | test.todo('should emit close after end called'); 39 | test.todo('should emit end after end classed and client must be disconnected'); 40 | test.todo('should pass store close error to end callback but not to end listeners (incomingStore)'); 41 | test.todo('should pass store close error to end callback but not to end listeners (outgoingStore)'); 42 | test.todo('should emit end only once'); 43 | test.todo('should stop ping timer after end called'); 44 | 45 | /* TODO: Stub out more tests */ 46 | 47 | /* =============================== END TESTS =============================== */ 48 | 49 | /* ====================== BEGIN after/afterEach HOOKS ====================== */ 50 | test.afterEach.always(cleanupBetweenTestsMacro); 51 | 52 | test.after.always(cleanupAfterAllTestsMacro); 53 | /* ======================= END after/afterEach HOOKS ======================= */ 54 | -------------------------------------------------------------------------------- /test/test_option_validation.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | test.todo('should mark the client as disconnected'); 4 | /* TODO: Test client options validation */ 5 | -------------------------------------------------------------------------------- /test/test_publish.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { logger } from '../dist/util/logger.js'; 3 | import { serverFactoryMacro, cleanupAfterAllTestsMacro, cleanupBetweenTestsMacro } from './util/testing_server_factory.js'; 4 | import { connect } from '../dist/index.js'; 5 | 6 | const port = 1887; 7 | 8 | /* ===================== BEGIN before/beforeEach HOOKS ===================== */ 9 | test.before('set up aedes broker', serverFactoryMacro, port); 10 | /* ====================== END before/beforeEach HOOKS ====================== */ 11 | 12 | /* ============================== BEGIN TESTS ============================== */ 13 | 14 | test.only('publish QoS 0', async (t) => { 15 | const connectReceivedPromise = new Promise((resolve) => { 16 | const connectReceivedListener = (packet) => { 17 | logger.info(`connect received: ${packet}`); 18 | if (!packet.clientId.startsWith('mqttjs_')) return; 19 | t.context.broker.removeListener('connectReceived', connectReceivedListener); 20 | resolve(packet); 21 | }; 22 | t.context.broker.on('connectReceived', connectReceivedListener); 23 | }); 24 | const client = await connect({ brokerUrl: `mqtt://localhost:${port}` }); 25 | const sentPacket = await connectReceivedPromise; 26 | 27 | client.on('error', (e) => { 28 | // TODO: When publishing a malformed publish packet, the client receives a ECONNRESET because of a disconnect. 29 | // We should figure out a better way to handle this error gracefully. 30 | logger.error(`Client emitted error: ${e.message}`); 31 | return t.fail(e.message); 32 | }); 33 | // validate the clientId received is thec correct clientId. 34 | t.deepEqual(sentPacket.clientId, client._options.clientId); 35 | 36 | t.context.broker.on('publish', async (packet, clientOnBroker) => { 37 | logger.info(`broker received publish on ${clientOnBroker}`); 38 | logger.info(`publish packet: ${JSON.stringify(packet)}`); 39 | if (clientOnBroker && clientOnBroker.id === client._options.clientId) { 40 | logger.info(`testing packet on client ${clientOnBroker.id}`); 41 | t.assert(packet.cmd === 'publish'); 42 | t.assert(packet.topic === 'fakeTopic'); 43 | t.assert(packet.payload === 'fakeMessage'); 44 | } 45 | logger.info(`calling disconnect.`); 46 | await client.disconnect(); 47 | }); 48 | 49 | logger.info(`calling publish.`); 50 | try { 51 | await client.publish({ topic: 'fakeTopic', payload: 'fakeMessage' }); 52 | } catch (e) { 53 | logger.error(`failed on publish with error: ${e}`); 54 | return t.fail(e.message); 55 | } 56 | }); 57 | 58 | test('handles error on malformed publish packet', async (t) => { 59 | const connectReceivedPromise = new Promise((resolve) => { 60 | const connectReceivedListener = (packet) => { 61 | logger.info(`connect received: ${packet}`); 62 | if (!packet.clientId.startsWith('mqttjs_')) return; 63 | t.context.broker.removeListener('connectReceived', connectReceivedListener); 64 | resolve(packet); 65 | }; 66 | t.context.broker.on('connectReceived', connectReceivedListener); 67 | }); 68 | const client = await connect({ brokerUrl: `mqtt://localhost:${port}` }); 69 | const sentPacket = await connectReceivedPromise; 70 | 71 | client.on('error', (e) => { 72 | // TODO: When publishing a malformed publish packet, the client receives a ECONNRESET because of a disconnect. 73 | // We should figure out a better way to handle this error gracefully. 74 | logger.error(`Client emitted error: ${e.message}`); 75 | return t.fail(e.message); 76 | }); 77 | // validate the clientId received is thec correct clientId. 78 | t.deepEqual(sentPacket.clientId, client._options.clientId); 79 | 80 | t.context.broker.on('publish', async (packet, clientOnBroker) => { 81 | logger.info(packet); 82 | // if (clientOnBroker && clientOnBroker.id === client._options.clientId) { 83 | // logger.info(`testing packet on client ${clientOnBroker.id}`) 84 | // t.assert(packet.cmd === 'publish') 85 | // t.assert(packet.topic === 'fakeTopic') 86 | // t.assert(packet.message === 'fakeMessage') 87 | // } 88 | // logger.info(`calling disconnect.`) 89 | // await client.disconnect(); 90 | }); 91 | 92 | logger.info(`calling publish.`); 93 | try { 94 | await client.publish({ topic: 'fakeTopic', message: 'fakeMessage' }); 95 | } catch (e) { 96 | logger.error(`failed on publish with error: ${e}`); 97 | return t.fail(e.message); 98 | } 99 | }); 100 | 101 | // test('client will PUBACK on QoS 1 Publish received from server', (t) => { 102 | // const connectReceivedPromise = new Promise((resolve) => { 103 | // const connectReceivedListener = (packet) => { 104 | // logger.info(`connect received: ${packet}`) 105 | // if (!packet.clientId.startsWith('mqttjs_')) return 106 | // t.context.broker.removeListener('connectReceived', connectReceivedListener) 107 | // resolve(packet); 108 | // } 109 | // t.context.broker.on('connectReceived', connectReceivedListener) 110 | // }); 111 | // const client = await connect({ brokerUrl: `mqtt://localhost:${port}`}); 112 | // const sentPacket = await connectReceivedPromise; 113 | 114 | // // validate the clientId received is the correct clientId. 115 | // t.deepEqual(sentPacket.clientId, client._options.clientId) 116 | 117 | // t.context.broker.on('client', (client) => { 118 | // logger.info(`new client: ${client.id}`) 119 | // }) 120 | 121 | // t.context.broker.on('publish', (packet, clientOnBroker) => { 122 | // if (clientOnBroker && clientOnBroker.id === client._options.clientId) { 123 | // logger.info(`testing packet on client ${clientOnBroker.id}`) 124 | // t.assert(packet.cmd === 'publish') 125 | // t.assert(packet.topic === 'fakeTopic') 126 | // t.assert(packet.message === 'fakeMessage') 127 | // logger.info(`calling disconnect.`) 128 | // await client.disconnect(); 129 | // } 130 | // }) 131 | 132 | // logger.info(`calling publish.`) 133 | // await client.publish({cmd: 'publish', topic: 'fakeTopic', message: 'fakeMessage'}); 134 | // }) 135 | 136 | test.todo('client handles malformed publish failure from mqtt-packet'); 137 | 138 | /* =============================== END TESTS =============================== */ 139 | 140 | /* ====================== BEGIN after/afterEach HOOKS ====================== */ 141 | test.afterEach.always(cleanupBetweenTestsMacro); 142 | 143 | test.after.always(cleanupAfterAllTestsMacro); 144 | /* ======================= END after/afterEach HOOKS ======================= */ 145 | -------------------------------------------------------------------------------- /test/test_timeout.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { logger } from '../dist/util/logger.js'; 3 | import { serverFactoryMacro, cleanupAfterAllTestsMacro, cleanupBetweenTestsMacro } from './util/testing_server_factory.js'; 4 | import { connect } from '../dist/index.js'; 5 | 6 | const port = 1888; 7 | 8 | /* ===================== BEGIN before/beforeEach HOOKS ===================== */ 9 | test.before('set up aedes broker', serverFactoryMacro, port); 10 | /* ====================== END before/beforeEach HOOKS ====================== */ 11 | 12 | /* ============================== BEGIN TESTS ============================== */ 13 | 14 | test.only('publish QoS 0', async (t) => { 15 | const connectReceivedListener = (packet) => { 16 | logger.test(`connect received: ${packet}`); 17 | t.context.broker.removeListener('connectReceived', connectReceivedListener); 18 | }; 19 | t.context.broker.on('connectReceived', connectReceivedListener); 20 | const client = await connect({ brokerUrl: `mqtt://localhost:${port}` }); 21 | await client.publish({ topic: 'fakeTopic', message: 'fakeMessage' }); 22 | await client.disconnect(); 23 | }); 24 | 25 | test('should checkPing at keepalive interval', (t) => { 26 | /* 27 | const client = connect({ keepalive: 3 }); 28 | 29 | client._checkPing = sinon.spy(); 30 | 31 | client.once('connect', function () { 32 | clock.tick(interval * 1000); 33 | assert.strictEqual(client._checkPing.callCount, 1); 34 | 35 | clock.tick(interval * 1000); 36 | assert.strictEqual(client._checkPing.callCount, 2); 37 | 38 | clock.tick(interval * 1000); 39 | assert.strictEqual(client._checkPing.callCount, 3); 40 | 41 | client.end(true, done); 42 | }); 43 | */ 44 | }); 45 | 46 | /* =============================== END TESTS =============================== */ 47 | 48 | /* ====================== BEGIN after/afterEach HOOKS ====================== */ 49 | test.afterEach.always(cleanupBetweenTestsMacro); 50 | 51 | test.after.always(cleanupAfterAllTestsMacro); 52 | /* ======================= END after/afterEach HOOKS ======================= */ 53 | -------------------------------------------------------------------------------- /test/util/generate_unique_port_number.js: -------------------------------------------------------------------------------- 1 | let originalPort = 1883; 2 | 3 | export function uniquePort() { 4 | return originalPort++; 5 | } 6 | -------------------------------------------------------------------------------- /test/util/testing_server_factory.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import aedes from 'aedes'; 3 | import { createServer } from 'node:net'; 4 | import { logger } from '../../dist/util/logger.js'; 5 | 6 | /* ===================== BEGIN before/beforeEach HOOKS ===================== */ 7 | export const serverFactoryMacro = test.macro(async (t, port) => { 8 | t.context.broker = aedes(); 9 | t.context.server = createServer(t.context.broker.handle); 10 | 11 | await new Promise((resolve) => t.context.server.listen(port, resolve)); 12 | 13 | logger.test(`server listening on port ${port}`); 14 | t.context.broker.on('clientError', (client, err) => { 15 | logger.test('client error', client.id, err.message, err.stack); 16 | }); 17 | t.context.broker.on('connectionError', (client, err) => { 18 | logger.test('connection error', client, err.message, err.stack); 19 | }); 20 | t.context.broker.on('subscribe', (subscriptions, client) => { 21 | if (client) { 22 | logger.test(`subscribe from client: ${subscriptions}, ${client.id}`); 23 | } 24 | }); 25 | t.context.broker.on('publish', (_packet, client) => { 26 | if (client) { 27 | logger.test(`message from client: ${client.id}`); 28 | } 29 | }); 30 | t.context.broker.on('client', (client) => { 31 | logger.test(`new client: ${client.id}`); 32 | }); 33 | t.context.broker.preConnect = (_client, packet, callback) => { 34 | t.context.broker.emit('connectReceived', packet); 35 | callback(null, true); 36 | }; 37 | }); 38 | 39 | export const cleanupBetweenTestsMacro = test.macro((t) => { 40 | t.context.broker?.removeAllListeners?.('connectReceived'); 41 | t.context.client?.disconnect?.({ force: false }); 42 | t.context.client = null; 43 | }); 44 | 45 | export const cleanupAfterAllTestsMacro = test.macro(async (t) => { 46 | t.context.server?.unref?.(); 47 | await new Promise((resolve) => { 48 | if (!t.context.broker?.close) { 49 | resolve(); 50 | return; 51 | } 52 | t.context.broker.close(resolve); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "sourceMap": true, 7 | "exactOptionalPropertyTypes": false, 8 | "strict": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noPropertyAccessFromIndexSignature": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "target": "ES2020", 17 | "module": "NodeNext", 18 | "declaration": true, 19 | "newLine": "LF", 20 | "noEmitOnError": true, 21 | "allowSyntheticDefaultImports": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "skipLibCheck": true 24 | } 25 | } 26 | --------------------------------------------------------------------------------