├── .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 | #
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 |
--------------------------------------------------------------------------------