hb-lib-tools
Version:
homebridge-lib Command-Line Tools`
493 lines (454 loc) • 16.2 kB
JavaScript
// hb-lib-tools/lib/HttpClient.js
//
// Library for Homebridge plugins.
// Copyright © 2018-2025 Erik Baauw. All rights reserved.
import { EventEmitter } from 'node:events'
import http from 'node:http'
import https from 'node:https'
import { OptionParser } from 'hb-lib-tools/OptionParser'
/** HTTP error.
* @hideconstructor
* @extends Error
* @memberof HttpClient
*/
class HttpError extends Error {
constructor (message, request, statusCode, statusMessage) {
super(message)
/** @member {HttpClient.HttpRequest} - The request that caused the error.
*/
this.request = request
/** @member {?integer} - The HTTP status code.
*/
this.statusCode = statusCode
/** @member {?string} - The HTTP status message.
*/
this.statusMessage = statusMessage
}
}
/** HTTP request.
* @hideconstructor
* @memberof HttpClient
*/
class HttpRequest {
constuctor (name, id, method, resource, headers, body, url) {
/** @member {string} - The server name.
*/
this.name = name
/** @member {integer} - The request ID.
*/
this.id = id
/** @member {string} - The request method.
*/
this.method = method
/** @member {string} - The requested resource.
*/
this.resource = resource
/** @member {object} - The request headers.
*/
this.headers = headers
/** @member {?string} - The request body.
*/
this.body = body
/** @member {string} - The full request URL.
*/
this.url = url
}
}
/** HTTP response.
* @hideconstructor
* @memberof HttpClient
*/
class HttpResponse {
constructor (request, statusCode, statusMessage, headers, body, parsedBody) {
/** @member {HttpClient.HttpRequest} - The request that generated the response.
*/
this.request = request
/** @member {integer} - The HTTP status code.
*/
this.statusCode = statusCode
/** @member {string} - The HTTP status message.
*/
this.statusMessage = statusMessage
/** @member {object} - The response headers.
*/
this.headers = headers
/** @member {?*} - The response body.
*/
this.body = body
/** @member {?*} - The parsed response body
* (in case of an XML response body).
*/
this.parsedBody = parsedBody
}
}
/** HTTP client.
* <br>See {@link HttpClient}.
* @name HttpClient
* @type {Class}
* @memberof module:hb-lib-tools
*/
/** HTTP client.
* @extends EventEmitter
*/
class HttpClient extends EventEmitter {
static get HttpError () { return HttpError }
static get HttpRequest () { return HttpRequest }
static get HttpResponse () { return HttpResponse }
/** Create a new instance of a client to an HTTP server.
*
* @param {object} params - Parameters.
* @param {string} [params.ca] - Certificate authority for the server.
* @param {function} [params.checkServerIdentity] - Custom function to check
* the server identity.
* @param {object} [params.headers={}] - Default HTTP headers for each request.
* @param {string} [params.host='localhost:80'] - Server hostname and port.
* @param {boolean} [params.https=false] - Use HTTPS (instead of HTTP).
* @param {boolean} [params.ipv6=false] - Use IPv6 (instead of IPv4).
* @param {boolean} [params.json=false] - Use JSON, i.e. request and response
* bodies are JSON strings.
* @param {boolean} [params.keepAlive=false] - Keep server connection(s) open.
* @param {integer} [params.maxSockets=Infinity] - Throttle requests to
* maximum number of parallel connections.
* @param {?string} params.name - The name of the server. Defaults to hostname.
* @param {string} [params.path=''] - Server base path.
* @param {boolean} [params.selfSignedCertificate=false] - Server uses a
* self-signed SSL certificate.
* @param {string} [params.suffix=''] - Base suffix to append after resource
* e.g. for authentication of the request.
* @param {boolean} [params.text=false] - Convert response body to text.
* @param {integer} [params.timeout=5] - Request timeout (in seconds).
* @param {integer[]} [params.validStatusCodes=[200]] - List of valid HTTP status codes.
* @param {?function} params.xmlParser - Parser for XML response body.
*/
constructor (params) {
super()
this.__params = {
headers: {},
hostname: 'localhost',
keepAlive: false,
maxSockets: Infinity,
path: '',
suffix: '',
timeout: 5,
validStatusCodes: [200]
}
const optionParser = new OptionParser(this.__params)
optionParser
.stringKey('ca')
.functionKey('checkServerIdentity')
.hostKey()
.boolKey('https')
.boolKey('ipv6')
.objectKey('headers')
.boolKey('json')
.boolKey('keepAlive')
.intKey('maxSockets', 1)
.stringKey('name', true)
.stringKey('path')
.boolKey('selfSignedCertificate')
.stringKey('suffix')
.boolKey('text', true)
.intKey('timeout', 1, 60)
.arrayKey('validStatusCodes')
.asyncFunctionKey('xmlParser')
.parse(params)
if (
this.__params.ca || this.__params.checkServerIdentity ||
this.__params.selfSignedCertificate
) {
this.__params.https = true
}
this._http = this.__params.https ? https : http
this.__options = {
agent: new this._http.Agent({
keepAlive: this.__params.keepAlive,
maxSockets: this.__params.maxSockets
}),
family: this.__params.ipv6 ? 6 : 4,
headers: Object.assign({}, this.__params.headers),
timeout: 1000 * this.__params.timeout
}
if (this.__params.ca != null) {
this.__options.ca = this.__params.ca
}
if (this.__params.checkServerIdentity != null) {
this.__options.checkServerIdentity = this.__params.checkServerIdentity
}
if (this.__params.selfSignedCertificate) {
this.__options.rejectUnauthorized = false
}
if (this.__params.json) {
const json = 'application/json;charset=utf-8'
if (this.__options.headers == null) {
this.__options.headers = {}
}
this.__options.headers['Content-Type'] = json
if (this.__options.headers.Accept == null) {
this.__options.headers.Accept = json
} else {
this.__options.headers.Accept += ',' + json
}
}
this._setUrl()
this.__requestId = 0
}
_setUrl () {
this.__params.url = this.__params.https ? 'https://' : 'http://'
this.__params.url += this.__params.hostname
if (this.__params.port != null) {
this.__params.url += ':' + this.__params.port
}
this.__params.url += this.__params.path
}
/** Server IP address.
* @type {string}
* @readonly
*/
get address () { return this.__params.address }
/** Server hostname and port.
* @type {string}
*/
get host () {
let host = this.__params.hostname
if (this.__params.port != null) {
host += ':' + this.__params.port
}
return host
}
set host (value) {
const obj = OptionParser.toHost('host', value)
this.__params.hostname = obj.hostname
this.__params.port = obj.port
this._setUrl()
}
/** Local IP address used for the connection.
* @type {string}
* @readonly
*/
get localAddress () { return this.__params.localAddress }
/** Server frienly name.
* Defaults to the hostname.
* @type {string}
*/
get name () {
return this.__params.name == null
? this.__params.hostname
: this.__params.name
}
set name (name) {
this.__params.name = name
}
/** Server (base) path.
* @type {string}
*/
get path () { return this.__params.path }
set path (value) {
this.__params.path = value == null
? ''
: OptionParser.toPath('path', value)
this._setUrl()
}
/** Server (base) url.
* @type {string}
* @readonly
*/
get url () { return this.__params.url }
/** GET request.
* @param {string} [resource='/'] - The resource.
* @param {?object} headers - Additional headers for the request.
* @param {?string} suffix - Additional suffix to append after resource
* e.g. for authentication of the request.
* @return {HttpClient.HttpResponse} response - The response.
* @throws {HttpClient.HttpError} In case of error.
*/
async get (resource = '/', headers, suffix) {
return this.request('GET', resource, undefined, headers, suffix)
}
/** PUT request.
* @param {!string} resource - The resource.
* @param {?*} body - The body for the request.
* @param {?object} headers - Additional headers for the request.
* @param {?string} suffix - Additional suffix to append after resource
* e.g. for authentication of the request.
* @return {HttpClient.HttpResponse} response - The response.
* @throws {HttpClient.HttpError} In case of error.
*/
async put (resource, body, headers, suffix) {
return this.request('PUT', resource, body, headers, suffix)
}
/** POST request.
* @param {!string} resource - The resource.
* @param {?*} body - The body for the request.
* @param {?object} headers - Additional headers for the request.
* @param {?string} suffix - Additional suffix to append after resource
* e.g. for authentication of the request.
* @return {HttpClient.HttpResponse} response - The response.
* @throws {HttpClient.HttpError} In case of error.
*/
async post (resource, body, headers, suffix) {
return this.request('POST', resource, body, headers, suffix)
}
/** DELETE request.
* @param {!string} resource - The resource.
* @param {?*} body - The body for the request.
* @param {?object} headers - Additional headers for the request.
* @param {?string} suffix - Additional suffix to append after resource
* e.g. for authentication of the request.
* @return {object} response - The response.
* @throws {HttpClient.HttpError} In case of error.
*/
async delete (resource, body, headers, suffix) {
return this.request('DELETE', resource, body, headers, suffix)
}
/** Issue an HTTP request.
* @param {string} method - The method for the request.
* @param {!string} resource - The resource for the request.
* @param {?*} body - The body for the request.
* @param {?object} headers - Additional headers for the request.
* @param {?string} suffix - Additional suffix to append after resource
* e.g. for authentication of the request.
* @param {?object} info - Additional key/value pairs to include in the
* for the `HttpRequest` of the `request`, `response`, and `error` events.
* @return {HttpClient.HttpResponse} response - The response.
* @throws {HttpClient.HttpError} In case of error.
*/
async request (method, resource, body, headers = {}, suffix = '', info = {}) {
return new Promise((resolve, reject) => {
method = OptionParser.toString('method', method, true)
if (!http.METHODS.includes(method)) {
throw new TypeError(`${method}: invalid method`)
}
resource = OptionParser.toString('resource', resource, true)
if (body != null && !Buffer.isBuffer(body)) {
body = this.__params.json
? JSON.stringify(body)
: OptionParser.toString('body', body)
}
const requestId = ++this.__requestId
const url = this.__params.url + (resource === '/' ? '' : resource) +
this.__params.suffix + suffix
const options = Object.assign({ method }, this.__options)
const requestInfo = Object.assign({
name: this.name,
id: requestId,
method,
resource,
body,
url
}, info)
const request = this._http.request(url, options)
request
.on('error', (error) => {
if (!(error instanceof HttpError)) {
error = new HttpError(error.message, requestInfo)
}
/** Emitted in case of error.
* @event HttpClient#error
* @param {HttpClient.HttpError} error - The error.
*/
this.emit('error', error)
reject(error)
})
.on('timeout', () => {
const error = new HttpError(
`timeout after ${this.__params.timeout} seconds`,
requestInfo, 408, 'Request Timeout'
)
request.destroy(error)
})
.on('socket', (socket) => {
if (
this.__params.address == null || this.__params.localAddress == null
) {
socket.once('connect', () => {
this.__params.address = socket.remoteAddress
this.__params.localAddress = socket.localAddress
})
}
/** Emitted when a request has been sent to the HTTP server.
* @event HttpClient#request
* @param {HttpClient.HttpRequest} request - The request.
*/
this.emit('request', requestInfo)
})
.on('response', (response) => {
if (this.__params.selfSignedCertificate && this.__params.checkServerIdentity) {
const error = this.__params.checkServerIdentity(
this.__params.hostname, response.socket.getPeerCertificate()
)
if (error != null) {
request.destroy(error)
return
}
}
const a = []
response
.on('data', (chunk) => { a.push(chunk) })
.on('end', async () => {
const buffer = Buffer.concat(a)
if (!this.__params.validStatusCodes.includes(response.statusCode)) {
request.emit('error',
new HttpError(
`http status ${response.statusCode} ${response.statusMessage}`,
requestInfo, response.statusCode, response.statusMessage
)
)
return
}
const responseInfo = {
request: requestInfo,
headers: response.headers,
statusCode: response.statusCode,
statusMessage: response.statusMessage,
body: null
}
if (buffer != null && buffer.length > 0) {
responseInfo.body = buffer
if (response.headers['content-type'] != null) {
if (
response.headers['content-type'].startsWith('application/json')
) {
try {
responseInfo.body = JSON.parse(buffer.toString('utf-8'))
} catch (error) {
request.emit('error', error)
return
}
} else if (response.headers['content-type'].startsWith('text')) {
responseInfo.body = buffer.toString('utf-8')
if (
response.headers['content-type'].startsWith('text/xml') &&
this.__params.xmlParser != null
) {
try {
responseInfo.parsedBody = await this.__params.xmlParser(
responseInfo.body
)
} catch (error) {
request.emit('error', error)
return
}
}
}
}
}
/** Emitted when a valid response has been received from the HTTP server.
* @event HttpClient#response
* @param {HttpClient.HttpResponse} response - The response.
*/
this.emit('response', responseInfo)
resolve(responseInfo)
})
})
if (headers != null) {
headers = OptionParser.toObject('headers', headers)
for (const header in headers) {
request.setHeader(header, headers[header])
}
}
requestInfo.headers = request.getHeaders()
request.end(body)
})
}
}
export { HttpClient }