diff --git a/package.json b/package.json index ca5fc6b..bbbc757 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,14 @@ { "name": "@meshtastic/js", - "version": "2.5.9-2", + "version": "2.5.9-3", "description": "Browser library for interfacing with meshtastic devices", "license": "GPL-3.0-only", "scripts": { "build": "tsup && pnpm biome format .", + "lint": "pnpm biome check", + "lint:fix": "pnpm biome check --fix", + "format": "pnpm biome format .", + "format:fix": "pnpm biome format . --fix", "generate:docs": "typedoc src/index.ts" }, "keywords": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f93cd1c..5687625 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@bufbuild/protobuf': specifier: ^2.2.1 - version: 2.2.1 + version: 2.2.3 '@meshtastic/protobufs': specifier: npm:@jsr/meshtastic__protobufs@^2.5.9 version: '@jsr/meshtastic__protobufs@2.5.9' @@ -101,8 +101,8 @@ packages: cpu: [x64] os: [win32] - '@bufbuild/protobuf@2.2.1': - resolution: {integrity: sha512-gdWzq7eX017a1kZCU/bP/sbk4e0GZ6idjsXOcMrQwODCb/rx985fHJJ8+hCu79KpuG7PfZh7bo3BBjPH37JuZw==} + '@bufbuild/protobuf@2.2.3': + resolution: {integrity: sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==} '@esbuild/aix-ppc64@0.23.1': resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} @@ -982,7 +982,7 @@ snapshots: '@biomejs/cli-win32-x64@1.9.3': optional: true - '@bufbuild/protobuf@2.2.1': {} + '@bufbuild/protobuf@2.2.3': {} '@esbuild/aix-ppc64@0.23.1': optional: true @@ -1084,7 +1084,7 @@ snapshots: '@jsr/meshtastic__protobufs@2.5.9': dependencies: - '@bufbuild/protobuf': 2.2.1 + '@bufbuild/protobuf': 2.2.3 '@pkgjs/parseargs@0.11.0': optional: true diff --git a/src/adapters/bleConnection.ts b/src/adapters/bleConnection.ts index 43ff8bd..d8ec87f 100755 --- a/src/adapters/bleConnection.ts +++ b/src/adapters/bleConnection.ts @@ -213,34 +213,33 @@ export class BleConnection extends MeshDevice { return await Promise.resolve(true); } - /** Short description */ + /** + * Reads data packets from the radio until empty + * @throws Error if reading fails + */ protected async readFromRadio(): Promise { - // if (this.pendingRead) { - // return Promise.resolve(); - // } - // this.pendingRead = true; - let readBuffer = new ArrayBuffer(1); - - while (readBuffer.byteLength > 0 && this.fromRadioCharacteristic) { - await this.fromRadioCharacteristic - .readValue() - .then((value) => { - readBuffer = value.buffer; - - if (value.byteLength > 0) { - this.handleFromRadio(new Uint8Array(readBuffer)); - } - this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected); - }) - .catch((e: Error) => { - readBuffer = new ArrayBuffer(0); - this.log.error( - Types.Emitter[Types.Emitter.ReadFromRadio], - `❌ ${e.message}`, - ); - }); + try { + let hasMoreData = true; + while (hasMoreData && this.fromRadioCharacteristic) { + const value = await this.fromRadioCharacteristic.readValue(); + + if (value.byteLength === 0) { + hasMoreData = false; + continue; + } + + await this.handleFromRadio(new Uint8Array(value.buffer)); + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected); + } + } catch (error) { + this.log.error( + Types.Emitter[Types.Emitter.ReadFromRadio], + `❌ ${error instanceof Error ? error.message : "Unknown error"}`, + ); + throw error; // Re-throw to let caller handle + } finally { + // this.pendingRead = false; } - // this.pendingRead = false; } /** diff --git a/src/adapters/httpConnection.ts b/src/adapters/httpConnection.ts index e2fc247..2b7675a 100755 --- a/src/adapters/httpConnection.ts +++ b/src/adapters/httpConnection.ts @@ -19,6 +19,13 @@ export class HttpConnection extends MeshDevice { private abortController: AbortController; + private readonly defaultRetryConfig: Types.HttpRetryConfig = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + backoffFactor: 2, + }; + constructor(configId?: number) { super(configId); @@ -37,6 +44,105 @@ export class HttpConnection extends MeshDevice { ); } + /** + * Checks if the error should trigger a retry attempt + * @param response - The fetch response + * @returns boolean indicating if should retry + */ + private shouldRetry(response: Response): boolean { + if (response.status >= 500 && response.status <= 599) { + return true; + } + + if (!response.ok && response.status < 400) { + return true; + } + + return false; + } + + /** + * Implements exponential backoff retry logic for HTTP operations + * @param operation - The async operation to retry + * @param retryConfig - Configuration for retry behavior + * @param operationName - Name of the operation for logging + */ + private async withRetry( + operation: () => Promise, + retryConfig: Types.HttpRetryConfig, + operationName: string, + ): Promise { + let delay = retryConfig.initialDelayMs; + + for (let attempt = 1; attempt <= retryConfig.maxRetries; attempt++) { + try { + const response = await operation(); + + // If the response is success or a non-retryable error, return it + if (!this.shouldRetry(response)) { + return response; + } + + const error = new Error( + `HTTP ${response.status}: ${response.statusText}`, + ); + + if (attempt === retryConfig.maxRetries) { + throw error; + } + + this.log.warn( + `${operationName} failed (attempt ${attempt}/${retryConfig.maxRetries}): ${error.message}`, + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min( + delay * retryConfig.backoffFactor, + retryConfig.maxDelayMs, + ); + } catch (error) { + // If it's not a Response error (e.g., network error), don't retry + if (!(error instanceof Error) || !error.message.startsWith("HTTP")) { + throw error; + } + + if (attempt === retryConfig.maxRetries) { + throw error; + } + + this.log.warn( + `${operationName} failed (attempt ${attempt}/${retryConfig.maxRetries}): ${error.message}`, + ); + } + } + + // This line should never be reached due to the error handling above, + throw new Error("Unexpected end of retry loop"); + } + + /** + * Attempts a single connection to the device + */ + private async attemptConnection( + params: Types.HttpConnectionParameters, + ): Promise { + const { address, tls = false } = params; + this.portId = `${tls ? "https://" : "http://"}${address}`; + + // We create a dummy request here just to have a Response object to work with + // The actual connection check is done via ping() + const response = await fetch(`${this.portId}/hotspot-detect.html`, { + signal: this.abortController.signal, + mode: "no-cors", + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response; + } + /** * Initiates the connect process to a Meshtastic device via HTTP(S) */ @@ -46,45 +152,73 @@ export class HttpConnection extends MeshDevice { receiveBatchRequests = false, tls = false, }: Types.HttpConnectionParameters): Promise { + // Set initial state this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnecting); - this.receiveBatchRequests = receiveBatchRequests; - this.portId = `${tls ? "https://" : "http://"}${address}`; - - if ( - this.deviceStatus === Types.DeviceStatusEnum.DeviceConnecting && - (await this.ping()) - ) { - this.log.debug( - Types.Emitter[Types.Emitter.Connect], - "Ping succeeded, starting configuration and request timer.", + try { + // Attempt connection with retries + await this.withRetry( + () => this.attemptConnection({ address, tls, fetchInterval }), + { + ...this.defaultRetryConfig, + maxRetries: 5, // More retries for initial connection + maxDelayMs: 10000, // Max 10s between retries + }, + "Connect", ); - this.configure().catch(() => { - // TODO: FIX, workaround for `wantConfigId` not getting acks. - }); - this.readLoop = setInterval(() => { - this.readFromRadio().catch((e: Error) => { - this.log.error( + + // If connection successful, set up device + if (this.deviceStatus === Types.DeviceStatusEnum.DeviceConnecting) { + this.log.debug( + Types.Emitter[Types.Emitter.Connect], + "Connection succeeded, starting configuration and request timer.", + ); + + // Start device configuration + await this.configure().catch((error) => { + this.log.warn( Types.Emitter[Types.Emitter.Connect], - `❌ ${e.message}`, + `Configuration warning: ${error.message}`, ); }); - }, fetchInterval); - } else if ( - this.deviceStatus !== Types.DeviceStatusEnum.DeviceDisconnected - ) { - setTimeout(() => { + + if (!this.readLoop) { + this.readLoop = setInterval(async () => { + try { + await this.readFromRadio(); + } catch (error) { + if (error instanceof Error) { + this.log.error( + Types.Emitter[Types.Emitter.Connect], + `❌ Read loop error: ${error.message}`, + ); + } + } + }, fetchInterval); + } + } + } catch (error) { + if (error instanceof Error) { + this.log.error( + Types.Emitter[Types.Emitter.Connect], + `❌ Connection failed: ${error.message}`, + ); + } + + // Only attempt reconnection if we haven't been disconnected + if (this.deviceStatus !== Types.DeviceStatusEnum.DeviceDisconnected) { + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceReconnecting); + this.connect({ - address: address, - fetchInterval: fetchInterval, - receiveBatchRequests: receiveBatchRequests, - tls: tls, + address, + fetchInterval, + receiveBatchRequests, + tls, }); - }, 10000); + } } } - /** Disconnects from the Meshtastic device */ public disconnect(): void { this.abortController.abort(); @@ -95,7 +229,7 @@ export class HttpConnection extends MeshDevice { } } - /** Pings device to check if it is avaliable */ + /** Pings device to check if it is available with retry logic */ public async ping(): Promise { this.log.debug( Types.Emitter[Types.Emitter.Ping], @@ -104,22 +238,34 @@ export class HttpConnection extends MeshDevice { const { signal } = this.abortController; - let pingSuccessful = false; + try { + const response = await this.withRetry( + async () => { + return await fetch(`${this.portId}/hotspot-detect.html`, { + signal, + mode: "no-cors", + }); + }, + { ...this.defaultRetryConfig }, + "Ping", + ); - await fetch(`${this.portId}/hotspot-detect.html`, { - signal, - mode: "no-cors", - }) - .then(() => { - pingSuccessful = true; - this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected); - }) - .catch((e: Error) => { - pingSuccessful = false; - this.log.error(Types.Emitter[Types.Emitter.Ping], `❌ ${e.message}`); - this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceReconnecting); - }); - return pingSuccessful; + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceConnected); + return true; + } catch (error) { + if (error instanceof Error) { + this.log.error( + Types.Emitter[Types.Emitter.Ping], + `❌ ${error.message}`, + ); + } + this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceReconnecting); + return false; + } } /** Reads any avaliable protobuf messages from the radio */ diff --git a/src/adapters/serialConnection.ts b/src/adapters/serialConnection.ts index 9e2b492..13a402b 100644 --- a/src/adapters/serialConnection.ts +++ b/src/adapters/serialConnection.ts @@ -25,7 +25,7 @@ export class SerialConnection extends MeshDevice { * through a transform stream (https://stackoverflow.com/questions/71262432) */ private pipePromise?: Promise; - /* Reference for the heartbeat ping interval so it can be canceled on disconnect. */ + /* Reference for the heartbeat ping interval so it can be canceled on disconnect. */ private heartbeatInterval?: ReturnType | undefined; /** @@ -161,9 +161,9 @@ export class SerialConnection extends MeshDevice { // The firmware requires at least one ping per 15 minutes, so this should be more than enough. this.heartbeatInterval = setInterval(() => { this.heartbeat().catch((err) => { - console.error('Heartbeat error', err); + console.error("Heartbeat error", err); }); - }, 60*1000); + }, 60 * 1000); } else { console.log("not readable or writable"); } @@ -193,11 +193,11 @@ export class SerialConnection extends MeshDevice { if (this.port?.readable) { await this.port?.close(); } - + // stop the interval when disconnecting. if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); - this.heartbeatInterval = undefined; + this.heartbeatInterval = undefined; } // ------- this.updateDeviceStatus(Types.DeviceStatusEnum.DeviceDisconnected); diff --git a/src/types.ts b/src/types.ts index b2955fc..46b5d90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,13 @@ export interface QueueItem { promise: Promise; } +export interface HttpRetryConfig { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; + backoffFactor: number; +} + export enum DeviceStatusEnum { DeviceRestarting = 1, DeviceDisconnected = 2, diff --git a/src/utils/customErrors.ts b/src/utils/customErrors.ts new file mode 100644 index 0000000..16d91e0 --- /dev/null +++ b/src/utils/customErrors.ts @@ -0,0 +1,29 @@ +import type { MeshDevice } from "./../meshDevice.ts"; + +export class HttpError extends Error { + constructor( + public readonly status: number, + public readonly statusText: string, + public readonly log: MeshDevice, + ) { + super(`HTTP ${status}: ${statusText}`); + + this.name = "HttpError"; + } +} + +/** + * Type guard for Error instances + * Narrows unknown error to Error type with message + */ +export function isError(error: unknown): error is Error { + return error instanceof Error; +} + +/** + * Type guard for HTTP errors + * Narrows unknown error to Error type with HTTP-specific message + */ +export function isHttpError(error: unknown): error is Error { + return isError(error) && error.message.startsWith("HTTP"); +} diff --git a/src/utils/general.ts b/src/utils/general.ts index 15d54df..4857fdd 100755 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -1,9 +1,34 @@ /** - * Converts a `Uint8Array` to an `ArrayBuffer` + * Converts a Uint8Array to an ArrayBuffer efficiently, with additional safety checks. + * @param array - The Uint8Array to convert + * @returns A new ArrayBuffer containing the Uint8Array data + * @throws { TypeError } If input is not a Uint8Array */ export const typedArrayToBuffer = (array: Uint8Array): ArrayBuffer => { + if (!(array instanceof Uint8Array)) { + throw new TypeError("Input must be a Uint8Array"); + } + + if (array.byteLength === 0) { + return new ArrayBuffer(0); + } + + // Check if the buffer is shared + if (array.buffer instanceof SharedArrayBuffer) { + // Always create a new buffer for shared memory + const newBuffer = new ArrayBuffer(array.byteLength); + new Uint8Array(newBuffer).set(array); + return newBuffer; + } + + // If array uses the entire buffer and isn't offset, return it directly + if (array.byteOffset === 0 && array.byteLength === array.buffer.byteLength) { + return array.buffer; + } + + // Otherwise, return a slice of the buffer containing just our data return array.buffer.slice( array.byteOffset, - array.byteLength + array.byteOffset, + array.byteOffset + array.byteLength, ); };