From 655f5728de28a466af768912ae0fc283a0c2c105 Mon Sep 17 00:00:00 2001 From: Ross Stenersen Date: Tue, 1 Jul 2025 13:39:51 -0500 Subject: [PATCH] refactor: update eventsource dependency and clean up code --- package-lock.json | 52 ++++--- package.json | 6 +- src/__tests__/lib/live-logging.test.ts | 168 +------------------- src/__tests__/lib/sse-util.test.ts | 14 -- src/__tests__/lib/util.test.ts | 12 ++ src/commands/edge/drivers/logcat.ts | 206 +++++++++++++++++++------ src/lib/command/sse-command.ts | 56 ------- src/lib/live-logging.ts | 100 +----------- src/lib/sse-util.ts | 16 -- src/lib/util.ts | 10 ++ 10 files changed, 218 insertions(+), 422 deletions(-) delete mode 100644 src/__tests__/lib/sse-util.test.ts delete mode 100644 src/lib/command/sse-command.ts delete mode 100644 src/lib/sse-util.ts diff --git a/package-lock.json b/package-lock.json index d6ed84da..8a69ddd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "@smartthings/cli", - "version": "1.10.4", + "version": "1.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@smartthings/cli", - "version": "1.10.4", + "version": "1.10.5", "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-lambda": "^3.817.0", "@inquirer/prompts": "^7.5.3", "@smartthings/core-sdk": "^8.4.1", - "axios": "1.9.0", + "axios": "1.10.0", "chalk": "^5.4.1", "env-paths": "^3.0.0", - "eventsource": "^2.0.2", + "eventsource": "^4.0.0", "express": "^5.1.0", "get-port-please": "^3.1.2", "inquirer": "^9.3.7", @@ -30,6 +30,7 @@ "qs": "^6.14.0", "table": "^6.9.0", "tslib": "^2.8.1", + "undici": "^7.11.0", "uuid": "^11.1.0", "yargs": "^18.0.0" }, @@ -43,7 +44,6 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@stylistic/eslint-plugin": "^2.8.0", - "@types/eventsource": "^1.1.15", "@types/express": "^5.0.2", "@types/inquirer": "^9.0.7", "@types/jest": "^29.5.14", @@ -4435,13 +4435,6 @@ "@types/node": "*" } }, - "node_modules/@types/eventsource": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", - "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/express": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.2.tgz", @@ -5361,9 +5354,9 @@ } }, "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -8138,12 +8131,24 @@ } }, "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.0.0.tgz", + "integrity": "sha512-fvIkb9qZzdMxgZrEQDyll+9oJsyaVvY92I2Re+qK0qEJ+w5s0X3dtz+M0VAPOjP1gtU3iqWyjQ0G3nvd5CLZ2g==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/execa": { @@ -16162,6 +16167,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", + "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index d0b0502b..bc307971 100644 --- a/package.json +++ b/package.json @@ -60,10 +60,10 @@ "@aws-sdk/client-lambda": "^3.817.0", "@inquirer/prompts": "^7.5.3", "@smartthings/core-sdk": "^8.4.1", - "axios": "1.9.0", + "axios": "1.10.0", "chalk": "^5.4.1", "env-paths": "^3.0.0", - "eventsource": "^2.0.2", + "eventsource": "^4.0.0", "express": "^5.1.0", "get-port-please": "^3.1.2", "inquirer": "^9.3.7", @@ -78,6 +78,7 @@ "qs": "^6.14.0", "table": "^6.9.0", "tslib": "^2.8.1", + "undici": "^7.11.0", "uuid": "^11.1.0", "yargs": "^18.0.0" }, @@ -88,7 +89,6 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@stylistic/eslint-plugin": "^2.8.0", - "@types/eventsource": "^1.1.15", "@types/express": "^5.0.2", "@types/inquirer": "^9.0.7", "@types/jest": "^29.5.14", diff --git a/src/__tests__/lib/live-logging.test.ts b/src/__tests__/lib/live-logging.test.ts index 910ba09d..95bfc6b6 100644 --- a/src/__tests__/lib/live-logging.test.ts +++ b/src/__tests__/lib/live-logging.test.ts @@ -2,15 +2,10 @@ import { jest } from '@jest/globals' import type { networkInterfaces } from 'node:os' import type { inspect } from 'node:util' -import type { PeerCertificate, TLSSocket } from 'node:tls' import type axios from 'axios' -import { AxiosError } from 'axios' import stripAnsi from 'strip-ansi' -import type { Authenticator } from '@smartthings/core-sdk' - -import type { HostVerifier, DriverInfo, LiveLogClientConfig } from '../../lib/live-logging.js' import type { LiveLogMessage } from '../../lib/sse-io.js' import type { fatalError } from '../../lib/util.js' @@ -27,8 +22,6 @@ jest.unstable_mockModule('node:util', () => ({ inspect: inspectMock, })) -const { debugMock, getLoggerMock, isDebugEnabledMock } = await import('../test-lib/logger-mock.js') - const requestMock = jest.fn() jest.unstable_mockModule('axios', () => ({ default: { @@ -46,13 +39,12 @@ const { handleConnectionErrors, liveLogMessageFormatter, logLevels, - newLiveLogClient, parseIpAndPort, } = await import('../../lib/live-logging.js') -/* eslint-disable @typescript-eslint/naming-convention */ describe('liveLogMessageFormatter', () => { + /* eslint-disable @typescript-eslint/naming-convention */ const errorEvent: LiveLogMessage = { timestamp: 'event timestamp', driver_id: 'driver-id', @@ -60,6 +52,7 @@ describe('liveLogMessageFormatter', () => { log_level: logLevels.error.value, message: 'Something bad happened.', } + /* eslint-enable @typescript-eslint/naming-convention */ it('returns timestamp in event format', () => { const format = liveLogMessageFormatter(errorEvent) @@ -138,160 +131,3 @@ describe('handleConnectionErrors', () => { expect(() => handleConnectionErrors('192.168.0.1', error)).not.toThrow() }) }) - -describe('newLiveLogClient', () => { - const authority = '192.168.222.1:9495' - const authHeaders = { 'Auth-Header': 'header-value' } - const authenticateMock = jest.fn().mockResolvedValue(authHeaders) - const authenticatorMock = { authenticate: authenticateMock } as unknown as Authenticator - const baseConfig: LiveLogClientConfig = { - authority, - authenticator: authenticatorMock, - timeout: 1000, - userAgent: 'user-agent', - } - - const driver1: DriverInfo = { driver_id: 'driver-id-1', driver_name: 'Driver 1', status: 'some-status' } - const driver2: DriverInfo = { driver_id: 'driver-id-2', driver_name: 'Driver 2', status: 'other-status' } - const hubDriverList = [driver1, driver2] - const certificate = { valid_from: 'yesterday about 3 a.m.' } as PeerCertificate - - it('generates populated LiveLogClient', () => { - expect(newLiveLogClient(baseConfig)).toStrictEqual({ - getDrivers: expect.any(Function), - getLogSource: expect.any(Function), - }) - - expect(getLoggerMock).toHaveBeenCalledExactlyOnceWith('cli') - }) - - describe('getDrivers', () => { - it('returns driver list', async () => { - const client = newLiveLogClient(baseConfig) - requestMock.mockResolvedValueOnce({ data: hubDriverList }) - - expect(await client.getDrivers()).toBe(hubDriverList) - - expect(authenticateMock).toHaveBeenCalledExactlyOnceWith() - - expect(requestMock).toHaveBeenCalledExactlyOnceWith({ - url: 'https://192.168.222.1:9495/drivers', - method: 'GET', - httpsAgent: expect.anything(), // TODO - timeout: 1000, - headers: { 'User-Agent': 'user-agent', ...authHeaders }, - transitional: { - silentJSONParsing: true, - forcedJSONParsing: true, - clarifyTimeoutError: true, - }, - }) - - expect(isDebugEnabledMock).not.toHaveBeenCalled() - expect(debugMock).not.toHaveBeenCalled() - }) - - it('verifies once an only once', async () => { - const verifierMock = jest.fn() - const config = { ...baseConfig, verifier: verifierMock } - const client = newLiveLogClient(config) - const getPeerCertificateMock = jest.fn().mockReturnValue(certificate) - requestMock.mockResolvedValue({ data: hubDriverList, request: { socket: { getPeerCertificate: getPeerCertificateMock } } }) - - expect(await client.getDrivers()).toBe(hubDriverList) - expect(verifierMock).toHaveBeenCalledExactlyOnceWith(certificate) - expect(getPeerCertificateMock).toHaveBeenCalledExactlyOnceWith() - expect(requestMock).toHaveBeenCalledTimes(1) - - // Still once even though another request has been made! - expect(await client.getDrivers()).toBe(hubDriverList) - expect(verifierMock).toHaveBeenCalledExactlyOnceWith(certificate) - expect(getPeerCertificateMock).toHaveBeenCalledExactlyOnceWith() - expect(requestMock).toHaveBeenCalledTimes(2) - }) - - it.each([ - { code: 'ECONNREFUSED', message: 'Unable to connect to 192.168.222.1:9495' }, - { code: 'EHOSTUNREACH', message: 'Unable to connect to 192.168.222.1:9495' }, - { code: 'ETIMEDOUT', message: 'Connection to 192.168.222.1:9495 timed out' }, - { code: 'EHOSTDOWN', message: 'The host at 192.168.222.1:9495 is down' }, - ])('handles %s axios error with user facing message', async ({ code, message }) => { - const axiosError = { code, isAxiosError: true } as AxiosError - const client = newLiveLogClient(baseConfig) - requestMock.mockRejectedValueOnce(axiosError) - - await expect(client.getDrivers()).rejects.toThrow(`${message}. Ensure hub address is correct and try again`) - }) - - const jsonError = { - request: { - headers: { - Authorization: 'Bearer 8a25775d-0e67-4dce-bbc9-0601ba6589ca', - }, - }, - } - it('logs axios error at debug level, scrubbing auth token', async () => { - isDebugEnabledMock.mockReturnValueOnce(true) - const toJSONMock = jest.fn().mockReturnValue(jsonError) - const axiosError = { code: 'ECONNREFUSED', isAxiosError: true } as AxiosError - axiosError.toJSON = toJSONMock - const client = newLiveLogClient(baseConfig) - requestMock.mockRejectedValueOnce(axiosError) - inspectMock.mockReturnValueOnce('inspected axios.toJSON Bearer 8a25775d-0e67-4dce-bbc9-0601ba6589ca') - inspectMock.mockReturnValueOnce('inspected network interfaces') - - await expect(client.getDrivers()).rejects.toThrow() - - expect(isDebugEnabledMock).toHaveBeenCalledExactlyOnceWith() - expect(inspectMock).toHaveBeenCalledTimes(2) - expect(inspectMock).toHaveBeenCalledWith(jsonError) - expect(inspectMock).toHaveBeenCalledWith(interfaces) - expect(debugMock).toHaveBeenCalledWith('Error connecting to live-logging: inspected axios.toJSON' + - ' Bearer 8a25775d-xxxx-xxxx-xxxx-xxxxxxxxxxxx\n\n' + - 'Local network interfaces: inspected network interfaces') - }) - - it('scrubs alternate auth token format', async () => { - isDebugEnabledMock.mockReturnValueOnce(true) - const toJSONMock = jest.fn().mockReturnValue(jsonError) - const axiosError = { code: 'ECONNREFUSED', isAxiosError: true } as AxiosError - axiosError.toJSON = toJSONMock - const client = newLiveLogClient(baseConfig) - requestMock.mockRejectedValueOnce(axiosError) - inspectMock.mockReturnValueOnce('inspected axios.toJSON Authorization: some-other-token-format') - inspectMock.mockReturnValueOnce('inspected network interfaces') - - await expect(client.getDrivers()).rejects.toThrow() - - expect(isDebugEnabledMock).toHaveBeenCalledExactlyOnceWith() - expect(inspectMock).toHaveBeenCalledTimes(2) - expect(inspectMock).toHaveBeenCalledWith(jsonError) - expect(inspectMock).toHaveBeenCalledWith(interfaces) - expect(debugMock).toHaveBeenCalledWith('Error connecting to live-logging: inspected axios.toJSON' + - ' Authorization: (redacted)\n\n' + - 'Local network interfaces: inspected network interfaces') - }) - - it('rethrows non-axios errors unchanged', async () => { - const error = Error('other error') - const client = newLiveLogClient(baseConfig) - requestMock.mockRejectedValueOnce(error) - - await expect(client.getDrivers()).rejects.toThrow(error) - }) - }) - - describe('getLogSource', () => { - const client = newLiveLogClient(baseConfig) - - it('returns URL with no query parameters for all drivers', () => { - expect(client.getLogSource()).toBe('https://192.168.222.1:9495/drivers/logs') - }) - - it('includes query parameter for a specific driver', () => { - expect(client.getLogSource('my-driver-id')) - .toBe('https://192.168.222.1:9495/drivers/logs?driver_id=my-driver-id') - }) - }) -}) -/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/__tests__/lib/sse-util.test.ts b/src/__tests__/lib/sse-util.test.ts deleted file mode 100644 index 8c041ca6..00000000 --- a/src/__tests__/lib/sse-util.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { jest } from '@jest/globals' - -import { handleSignals, sseSignals } from '../../lib/sse-util.js' - - -test('handleSignals adds handler for all required Signals', () => { - const handler = jest.fn() - - handleSignals(handler) - - sseSignals.forEach(signal => { - expect(process.listeners(signal)).toContain(handler) - }) -}) diff --git a/src/__tests__/lib/util.test.ts b/src/__tests__/lib/util.test.ts index aab7ddaa..274c1436 100644 --- a/src/__tests__/lib/util.test.ts +++ b/src/__tests__/lib/util.test.ts @@ -6,7 +6,9 @@ import { clipToMaximum, delay, fatalError, + handleSignals, sanitize, + terminationSignals, stringFromUnknown, } from '../../lib/util.js' @@ -113,3 +115,13 @@ describe('asTextBulletedList', () => { expect(asTextBulletedList(['one', 'two', 'three'])).toBe('\n - one\n - two\n - three') }) }) + +test('handleSignals adds handler for all required Signals', () => { + const handler = jest.fn() + + handleSignals(handler) + + terminationSignals.forEach(signal => { + expect(process.listeners(signal)).toContain(handler) + }) +}) diff --git a/src/commands/edge/drivers/logcat.ts b/src/commands/edge/drivers/logcat.ts index d17c86dc..9032f337 100644 --- a/src/commands/edge/drivers/logcat.ts +++ b/src/commands/edge/drivers/logcat.ts @@ -1,24 +1,29 @@ -import { PeerCertificate } from 'node:tls' +import { type ClientRequest } from 'node:http' +import { Agent as AxiosAgent } from 'node:https' +import { type PeerCertificate, type TLSSocket } from 'node:tls' import { inspect } from 'node:util' +import axios, { type AxiosError, type AxiosResponse, type Method } from 'axios' +import { type ErrorEvent, EventSource } from 'eventsource' +import log4js from 'log4js' import ora from 'ora' +import { fetch, Agent } from 'undici' import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' import { green, red } from '../../../lib/colors.js' import { + type DriverInfo, handleConnectionErrors, - type LiveLogClientConfig, liveLogMessageFormatter, type LogLevel, logLevels, - newLiveLogClient, + networkEnvironmentInfo, parseIpAndPort, + scrubAuthInfo, } from '../../../lib/live-logging.js' import { logEvent, parseEvent } from '../../../lib/sse-io.js' -import { EventSourceError, handleSignals } from '../../../lib/sse-util.js' -import { fatalError } from '../../../lib/util.js' -import { apiCommand, apiCommandBuilder, APICommandFlags, userAgent } from '../../../lib/command/api-command.js' -import { initSource } from '../../../lib/command/sse-command.js' +import { fatalError, handleSignals } from '../../../lib/util.js' +import { apiCommand, apiCommandBuilder, type APICommandFlags, userAgent } from '../../../lib/command/api-command.js' import { chooseHub } from '../../../lib/command/util/hubs-choose.js' import { checkServerIdentity, chooseHubDrivers } from '../../../lib/command/util/hub-drivers.js' @@ -39,7 +44,7 @@ export type CommandArgs = const command = 'edge:drivers:logcat [driver-id]' -const describe = 'stream logs from installed drivers' +const describe = 'stream logs from installed drivers, simple temporary hard-coded version' const builder = (yargs: Argv): Argv => apiCommandBuilder(yargs) @@ -101,71 +106,152 @@ const handler = async (argv: ArgumentsCamelCase): Promise => const liveLogPort = port ?? defaultLiveLogPort const authority = `${ipv4}:${liveLogPort}` const spinner = ora() + const verifier = (cert: PeerCertificate): Promise => checkServerIdentity(command, authority, cert) + const timeout = argv.connectTimeout ?? defaultLiveLogTimeout - const config: LiveLogClientConfig = { - authority, - authenticator: command.authenticator, - verifier: (cert: PeerCertificate) => checkServerIdentity(command, authority, cert), - timeout: argv.connectTimeout ?? defaultLiveLogTimeout, - userAgent, + const baseURL = new URL(`https://${authority}`) + + const driversURL = new URL('drivers', baseURL) + const logsURL = new URL('drivers/logs', baseURL) + let hostVerified = false + const logger = log4js.getLogger('cli') + + const getCertificate = (response: AxiosResponse): PeerCertificate => + ((response.request as ClientRequest).socket as TLSSocket).getPeerCertificate() + + const unsafeAgent = new Agent({ + connect: { + rejectUnauthorized: false, + }, + }) + const request = async (url: string, method: Method = 'GET'): Promise => { + // Even though we are using `fetch` from `undici` below, sticking to axios here, at least + // for now, because I have been unable to make `fetch` work here. + const authHeaders = await command.authenticator.authenticate() + const requestConfig = { + url, + method, + httpsAgent: new AxiosAgent({ rejectUnauthorized: false }), + timeout, + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { 'User-Agent': userAgent, ...authHeaders }, + transitional: { + silentJSONParsing: true, + forcedJSONParsing: true, + // throw ETIMEDOUT error instead of generic ECONNABORTED on request timeouts + clarifyTimeoutError: true, + }, + } + + try { + const response = await axios.request(requestConfig) + if (!hostVerified) { + await verifier(getCertificate(response)) + hostVerified = true + } + + return response + } catch (error) { + console.log('*** error caught') + if (error.isAxiosError) { + const axiosError = error as AxiosError + if (logger.isDebugEnabled()) { + const errorString = scrubAuthInfo(axiosError.toJSON()) + logger.debug(`Error connecting to live-logging: ${errorString}\n\nLocal network interfaces:` + + ` ${networkEnvironmentInfo()}`) + } + + if (axiosError.code) { + handleConnectionErrors(authority, axiosError.code) + } + } + + throw error + } } - const logClient = newLiveLogClient(config) + const getDrivers = async (): Promise => (await request(driversURL.toString())).data + + const getLogSource = (driverId?: string): string => { + const sourceURL = logsURL + + if (driverId) { + sourceURL.searchParams.set('driver_id', driverId) + } + + return sourceURL.toString() + } // ensure host verification resolves before connecting to the event source - const installedDrivers = await logClient.getDrivers() + const installedDrivers = await getDrivers() const driverId = argv.all ? undefined : await chooseHubDrivers(command, installedDrivers, argv.driverId) spinner.start('connecting') - const sourceURL = logClient.getLogSource(driverId) + const sourceURL = getLogSource(driverId) - const { source, teardown } = await initSource(command, sourceURL) + // assume auth is taken care of if passing an initDict + const authHeaders = await command.authenticator.authenticate() - const sourceTimeoutId = setTimeout(() => { - teardown() - spinner.fail(red('failed')) + // eslint-disable-next-line @typescript-eslint/naming-convention + const headers: HeadersInit = { 'User-Agent': userAgent, ...authHeaders } + + const source = new EventSource(sourceURL, { + fetch: async (input, init) => { + // I haven't been able to successfully make this request with axios, so for now, we're + // sticking to how the eventsource examples do it with `fetch` from `undici`. + const results = await fetch(input.toString(), { + ...init, + dispatcher: unsafeAgent, + headers: { + ...init.headers, + ...headers, + }, + }) + + return results + }, + }) + + const teardown = (): void => { try { - handleConnectionErrors(authority, 'ETIMEDOUT') + source.close() } catch (error) { - if (error instanceof Error) { - return fatalError(error) - } + command.logger.warn(`Error during SseCommand teardown. ${error.message ?? error}`) } - }, argv.connectTimeout).unref() // unref lets Node exit before callback is invoked - - const setupSignalHandler = (): void => { - handleSignals(signal => { - command.logger.debug(`handling ${signal} and tearing down SseCommand`) - - teardown() - }) } - source.onopen = () => { - clearTimeout(sourceTimeoutId) + source.addEventListener('notice', event => { + command.logger.warn(`unexpected notice event: ${inspect(event)}`) + }) - if (installedDrivers.length === 0) { - console.warn('No drivers currently installed.') - } + source.addEventListener('update', event => { + command.logger.warn(`unexpected update event: ${inspect(event)}`) + }) - spinner.succeed(green('connected')) - setupSignalHandler() - } + const logLevel = logLevels[argv.logLevel ?? 'trace'] + source.addEventListener('message', event => { + const message = parseEvent(event) + if (message.log_level >= logLevel.value) { + logEvent(message, liveLogMessageFormatter) + } + }) - source.onerror = (error: EventSourceError) => { + source.addEventListener('error', (error: ErrorEvent) => { teardown() spinner.fail(red('failed')) + command.logger.debug(`Error from eventsource. URL: ${sourceURL} Error: ${inspect(error)}`) + try { - if (error.status === 401 || error.status === 403) { + if (error.code === 401 || error.code === 403) { return fatalError(`Unauthorized at ${authority}`) } if (error.message !== undefined) { - handleConnectionErrors(authority, error.message) + return handleConnectionErrors(authority, error.message) } console.error(`Unexpected error from event source ${inspect(error)}`) @@ -174,14 +260,34 @@ const handler = async (argv: ArgumentsCamelCase): Promise => return fatalError(error) } } - } + }) - const logLevel = logLevels[argv.logLevel ?? 'trace'] - source.onmessage = (event: MessageEvent) => { - const message = parseEvent(event) - if (message.log_level >= logLevel.value) { - logEvent(message, liveLogMessageFormatter) + const sourceTimeoutId = setTimeout(() => { + teardown() + spinner.fail(red('failed')) + try { + handleConnectionErrors(authority, 'ETIMEDOUT') + } catch (error) { + if (error instanceof Error) { + return fatalError(error) + } + } + }, argv.connectTimeout).unref() // unref lets Node exit before callback is invoked + + handleSignals(signal => { + command.logger.debug(`handling ${signal} and tearing down SseCommand`) + + teardown() + }) + + source.onopen = () => { + clearTimeout(sourceTimeoutId) + + if (installedDrivers.length === 0) { + console.warn('No drivers currently installed.') } + + spinner.succeed(green('connected')) } } diff --git a/src/lib/command/sse-command.ts b/src/lib/command/sse-command.ts deleted file mode 100644 index d385c2d8..00000000 --- a/src/lib/command/sse-command.ts +++ /dev/null @@ -1,56 +0,0 @@ -import EventSource from 'eventsource' - -import { HttpClientHeaders } from '@smartthings/core-sdk' - -import { EventSourceError } from '../sse-util.js' -import { type APICommand, userAgent } from './api-command.js' - - -export const initSource = async ( - command: APICommand, - url: string, - sourceInitDict?: EventSource.EventSourceInitDict, -): Promise<{ source: EventSource; teardown: () => void }> => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const headers: HttpClientHeaders = { 'User-Agent': userAgent } - - // assume auth is taken care of if passing an initDict - if (!sourceInitDict) { - const authHeaders = await command.authenticator.authenticate() - sourceInitDict = { headers: { ...headers, ...authHeaders } } - } else { - sourceInitDict = { ...sourceInitDict, headers: { ...headers, ...sourceInitDict?.headers } } - } - - const source = new EventSource(url, sourceInitDict) - const teardown = (): void => { - try { - source.close() - } catch (error) { - command.logger.warn(`Error during SseCommand teardown. ${error.message ?? error}`) - } - } - - source.onerror = (error: EventSourceError) => { - teardown() - - let message - if (error) { - if (error.status) { - if (error.status === 401 || error.status === 403) { - message = `Event source not authorized. ${error.message}` - } else { - message = `Event source error ${error.status}. ${error.message}` - } - } else { - message = `Event source error. ${error.message}` - } - } else { - message = 'Unexpected event source error.' - } - - console.error(message) - } - - return { source, teardown } -} diff --git a/src/lib/live-logging.ts b/src/lib/live-logging.ts index 298b4d6c..1ee5e508 100644 --- a/src/lib/live-logging.ts +++ b/src/lib/live-logging.ts @@ -1,15 +1,8 @@ -import { type ClientRequest } from 'node:http' -import { Agent } from 'node:https' import net from 'node:net' import { networkInterfaces } from 'node:os' -import { type PeerCertificate, type TLSSocket } from 'node:tls' +import { type PeerCertificate } from 'node:tls' import { inspect } from 'node:util' -import axios, { AxiosError, AxiosResponse, Method } from 'axios' -import log4js from 'log4js' - -import { type Authenticator } from '@smartthings/core-sdk' - import { bgBlue, bgCyan, bgGray, bgGreen, bgRed, bgYellow, black } from './colors.js' import { type EventFormat, LiveLogMessage } from './sse-io.js' import { fatalError } from './util.js' @@ -106,7 +99,7 @@ export const handleConnectionErrors = (authority: string, error: string): never export const networkEnvironmentInfo = (): string => inspect(networkInterfaces()) -const scrubAuthInfo = (obj: object): string => { +export const scrubAuthInfo = (obj: object): string => { const message = inspect(obj) const bearerRegex = /(Bearer [0-9a-f]{8})[0-9a-f-]{28}/i @@ -123,92 +116,3 @@ const scrubAuthInfo = (obj: object): string => { * by means that LiveLogClient isn't aware of ahead of time. */ export type HostVerifier = (cert: PeerCertificate) => Promise - -export type LiveLogClientConfig = { - /** - * @example 192.168.0.1:9495 - */ - authority: string - authenticator: Authenticator - verifier?: HostVerifier - /** - * milliseconds - */ - timeout: number - userAgent: string -} - -export type LiveLogClient = { - getDrivers(): Promise - getLogSource(driverId?: string): string -} - -export const newLiveLogClient = (config: LiveLogClientConfig): LiveLogClient => { - const baseURL = new URL(`https://${config.authority}`) - - const driversURL = new URL('drivers', baseURL) - const logsURL = new URL('drivers/logs', baseURL) - let hostVerified = config.verifier === undefined - const logger = log4js.getLogger('cli') - - const getCertificate = (response: AxiosResponse): PeerCertificate => - ((response.request as ClientRequest).socket as TLSSocket).getPeerCertificate() - - const request = async (url: string, method: Method = 'GET'): Promise => { - const authHeaders = await config.authenticator.authenticate() - const requestConfig = { - url, - method, - httpsAgent: new Agent({ rejectUnauthorized: false }), - timeout: config.timeout, - // eslint-disable-next-line @typescript-eslint/naming-convention - headers: { 'User-Agent': config.userAgent, ...authHeaders }, - transitional: { - silentJSONParsing: true, - forcedJSONParsing: true, - // throw ETIMEDOUT error instead of generic ECONNABORTED on request timeouts - clarifyTimeoutError: true, - }, - } - - try { - const response = await axios.request(requestConfig) - - if (!hostVerified && config.verifier) { - await config.verifier(getCertificate(response)) - hostVerified = true - } - - return response - } catch (error) { - if (error.isAxiosError) { - const axiosError = error as AxiosError - if (logger.isDebugEnabled()) { - const errorString = scrubAuthInfo(axiosError.toJSON()) - logger.debug(`Error connecting to live-logging: ${errorString}\n\nLocal network interfaces:` + - ` ${networkEnvironmentInfo()}`) - } - - if (axiosError.code) { - handleConnectionErrors(config.authority, axiosError.code) - } - } - - throw error - } - } - - const getDrivers = async (): Promise => (await request(driversURL.toString())).data - - const getLogSource = (driverId?: string): string => { - const sourceURL = logsURL - - if (driverId) { - sourceURL.searchParams.set('driver_id', driverId) - } - - return sourceURL.toString() - } - - return { getDrivers, getLogSource } -} diff --git a/src/lib/sse-util.ts b/src/lib/sse-util.ts deleted file mode 100644 index 74e2cb63..00000000 --- a/src/lib/sse-util.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const sseSignals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'] - -/** - * Listen for various NodeJS Signals for the purpose of best effort resource/connection cleanup. - * - * see: https://nodejs.org/api/process.html#process_signal_events - */ -export const handleSignals = (listener: NodeJS.SignalsListener): void => - sseSignals.forEach(signal => process.on(signal, listener)) - -/** - * error Event from eventsource doesn't always overlap with MessageEvent - * - * TODO: update DefinitelyTyped https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/eventsource - */ -export type EventSourceError = MessageEvent & { status?: number; message?: string } diff --git a/src/lib/util.ts b/src/lib/util.ts index 3d7a9157..3f2f9f04 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -59,3 +59,13 @@ export const cancelCommand = (message?: string): never => { export const asTextBulletedList = (enums: string[]): string => enums.length === 0 ? '' : ('\n - ' + enums.join('\n - ')) + +export const terminationSignals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'] + +/** + * Listen for various NodeJS Signals for the purpose of best effort resource/connection cleanup. + * + * see: https://nodejs.org/api/process.html#process_signal_events + */ +export const handleSignals = (listener: NodeJS.SignalsListener): void => + terminationSignals.forEach(signal => process.on(signal, listener))