From f03a28954ad03eeaf700743f42f8dbdd09bd14ae Mon Sep 17 00:00:00 2001 From: foomoon Date: Sat, 6 Jan 2024 19:10:04 -0600 Subject: [PATCH] support fetch and axios --- dist/lib/fetchwrapper.d.ts | 22 +++++ dist/lib/fetchwrapper.js | 90 ++++++++++++++++++ dist/lib/light.d.ts | 55 ++++++----- dist/lib/light.js | 174 +++++++++++++++++++---------------- src/lib/fetchwrapper.ts | 120 ++++++++++++++++++++++++ src/lib/light.ts | 183 ++++++++++++++++++++----------------- 6 files changed, 456 insertions(+), 188 deletions(-) create mode 100644 dist/lib/fetchwrapper.d.ts create mode 100644 dist/lib/fetchwrapper.js create mode 100644 src/lib/fetchwrapper.ts diff --git a/dist/lib/fetchwrapper.d.ts b/dist/lib/fetchwrapper.d.ts new file mode 100644 index 0000000..6b176fe --- /dev/null +++ b/dist/lib/fetchwrapper.d.ts @@ -0,0 +1,22 @@ +export default class FetchWrapper { + private baseURL; + private timeout; + defaults: FetchDefaults; + constructor(baseURL?: string, timeout?: number); + addHeaders(newHeaders: Record): void; + request(endpoint: string, options?: RequestInit): Promise; + get(endpoint: string, options?: RequestInit): Promise; + post(endpoint: string, body: any, options?: RequestInit): Promise; + delete(endpoint: string, data: any): Promise; + put(endpoint: string, body: any, options?: RequestInit): Promise; + private stringifyBodyIfJson; + private createTimeoutPromise; + private setHeaderIfJson; + private handleResponseError; +} +export interface FetchDefaults { + headers: Record; +} +export interface FetchResponse extends Response { + data?: any; +} diff --git a/dist/lib/fetchwrapper.js b/dist/lib/fetchwrapper.js new file mode 100644 index 0000000..1931bc8 --- /dev/null +++ b/dist/lib/fetchwrapper.js @@ -0,0 +1,90 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +export default class FetchWrapper { + constructor(baseURL = "", timeout = 5000) { + this.defaults = { + headers: {}, + }; + this.baseURL = baseURL; + this.timeout = timeout; + } + addHeaders(newHeaders) { + this.defaults.headers = Object.assign(Object.assign({}, this.defaults.headers), newHeaders); + } + request(endpoint, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + this.stringifyBodyIfJson(options); + const url = `${this.baseURL}${endpoint}`; + options.headers = Object.assign(Object.assign({}, this.defaults.headers), options.headers); + try { + const response = yield Promise.race([ + fetch(url, options), + this.createTimeoutPromise(), + ]); + const data = yield response.json(); + this.handleResponseError(response, data); + const combinedFetchResponse = Object.assign(Object.assign({}, response), { data }); + return combinedFetchResponse; + } + catch (error) { + throw error; + } + }); + } + get(endpoint, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "GET" })); + }); + } + post(endpoint, body, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + this.setHeaderIfJson(options); + return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "POST", body })); + }); + } + delete(endpoint, data) { + return __awaiter(this, void 0, void 0, function* () { + return this.request(endpoint, { + method: "DELETE", + body: JSON.stringify(data), + }); + }); + } + put(endpoint, body, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + this.setHeaderIfJson(options); + return this.request(endpoint, Object.assign(Object.assign({}, options), { method: "PUT", body: JSON.stringify(body) })); + }); + } + stringifyBodyIfJson(options) { + if (!options.headers) { + return; + } + if (options.headers["Content-Type"] === "application/json") { + options.body = JSON.stringify(options.body); + } + } + createTimeoutPromise() { + return new Promise((_, reject) => setTimeout(() => reject(new Error(`Request was aborted due to a timeout < ${this.timeout}ms. Increase the timeout to avoid this error.`)), this.timeout)); + } + setHeaderIfJson(options) { + if (!options.headers) { + options.headers = {}; + } + if (!options.headers["Content-Type"]) { + options.headers["Content-Type"] = "application/json"; + } + } + handleResponseError(response, data) { + if (!response.ok) { + throw new Error(data.message || "Error fetching data"); + } + } +} diff --git a/dist/lib/light.d.ts b/dist/lib/light.d.ts index d008db5..d69196c 100644 --- a/dist/lib/light.d.ts +++ b/dist/lib/light.d.ts @@ -1,5 +1,6 @@ /// import { AxiosInstance, AxiosResponse } from "axios"; +import FetchWrapper, { FetchResponse } from "./fetchwrapper.js"; import { Frame } from "./frame.js"; import { Movie } from "./movie.js"; import { rgbColor, hsvColor, deviceMode, timer, coordinate, layout } from "./interfaces.js"; @@ -11,7 +12,7 @@ import { rgbColor, hsvColor, deviceMode, timer, coordinate, layout } from "./int export declare class Light { ipaddr: string; challenge: string; - net: AxiosInstance; + net: AxiosInstance | FetchWrapper; token: AuthenticationToken | undefined; activeLoginCall: boolean; nleds: number | undefined; @@ -22,8 +23,28 @@ export declare class Light { * @constructor * @param {string} ipaddr IP Address of the Twinkly device */ - constructor(ipaddr: string, timeout?: number); - autoEndLoginCall(): Promise; + constructor(ipaddr: string, timeout?: number, useFetch?: boolean); + /** + * Sends a POST request to the device, appending the required tokens + * + * @param {string} url + * @param {object} params + */ + sendPostRequest(url: string, data?: any, contentType?: string): Promise; + /** + * Sends a DELETE request to the device, appending the required tokens + * + * @param {string} url + * @param {object} data + */ + sendDeleteRequest(url: string, data: any): Promise; + /** + * Sends a GET request to the device, appending the required tokens + * + * @param {string} url + * @param {object} params + */ + sendGetRequest(url: string, params?: object, requiresToken?: boolean): Promise; /** * Sends a login request * @@ -34,6 +55,10 @@ export declare class Light { * Sends a logout request */ logout(): Promise; + /** + * Automatically ends a login call after 1 second + */ + autoEndLoginCall(): Promise; /** * Check that we are logged in to the device */ @@ -146,26 +171,6 @@ export declare class Light { * @param {deviceMode} mode */ setMode(mode: deviceMode): Promise; - /** - * Sends a POST request to the device, appending the required tokens - * - * @param {string} url - * @param {object} params - */ - sendPostRequest(url: string, data: any, contentType?: string): Promise; - /** - * - * @param {string} url - * @param {object} data - */ - sendDeleteRequest(url: string, data: any): Promise; - /** - * Sends a GET request to the device, appending the required tokens - * - * @param {string} url - * @param {object} params - */ - sendGetRequest(url: string, params?: object, requiresToken?: boolean): Promise; /** * Send a movie config to the device * @@ -312,9 +317,9 @@ export declare class AuthenticationToken { * Creates an instance of AuthenticationToken. * * @constructor - * @param {AxiosResponse} res Response from POST request + * @param {AxiosResponse | FetchResponse} res Response from POST request */ - constructor(res: AxiosResponse); + constructor(res: AxiosResponse | FetchResponse); /** * * @returns Token as string diff --git a/dist/lib/light.js b/dist/lib/light.js index dd808de..ca1b6ef 100644 --- a/dist/lib/light.js +++ b/dist/lib/light.js @@ -9,6 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; import { generateRandomHex } from "./utils.js"; import axios from "axios"; +import FetchWrapper from "./fetchwrapper.js"; import delay from "delay"; // dynamically import udp for compatibility with browser // import * as udp from "node:dgram"; @@ -30,13 +31,19 @@ export class Light { * @constructor * @param {string} ipaddr IP Address of the Twinkly device */ - constructor(ipaddr, timeout = 20000) { + constructor(ipaddr, timeout = 20000, useFetch = false) { this.ipaddr = ipaddr; this.challenge = ""; // default value, will be set in login() - this.net = axios.create({ + const config = { baseURL: `http://${this.ipaddr}/xled/v1/`, timeout: timeout, - }); + }; + if (useFetch) { + this.net = new FetchWrapper(config.baseURL, config.timeout); + } + else { + this.net = axios.create(config); + } this.activeLoginCall = false; // dynamically import udp asynchroniously with IIFE (() => __awaiter(this, void 0, void 0, function* () { @@ -57,10 +64,77 @@ export class Light { } }))(); } - autoEndLoginCall() { + /** + * Sends a POST request to the device, appending the required tokens + * + * @param {string} url + * @param {object} params + */ + sendPostRequest(url, data = {}, contentType = "application/json") { return __awaiter(this, void 0, void 0, function* () { - yield delay(1000); - this.activeLoginCall = false; + if (!this.token) + throw errNoToken; + let res; + try { + res = yield this.net.post(url, data, { + headers: { + "Content-Type": contentType, + }, + }); + } + catch (err) { + throw err; + } + if (res.data.code != applicationResponseCode.Ok) { + throw Error(`Mode set failed with error code ${res.data.code}`); + } + return res.data; + }); + } + /** + * Sends a DELETE request to the device, appending the required tokens + * + * @param {string} url + * @param {object} data + */ + sendDeleteRequest(url, data) { + return __awaiter(this, void 0, void 0, function* () { + if (!this.token) + throw errNoToken; + let res; + try { + res = yield this.net.delete(url, data); + } + catch (err) { + throw err; + } + if (res.data.code != applicationResponseCode.Ok) { + throw Error(`Mode set failed with error code ${res.data.code}`); + } + return res.data; + }); + } + /** + * Sends a GET request to the device, appending the required tokens + * + * @param {string} url + * @param {object} params + */ + sendGetRequest(url, params, requiresToken = true) { + return __awaiter(this, void 0, void 0, function* () { + if (!this.token && requiresToken) + throw errNoToken; + let res; + try { + res = yield this.net.get(url, params || {}); + } + catch (err) { + throw err; + } + if (res.data.code != applicationResponseCode.Ok) { + throw Error(`Request failed with error code ${res.data.code}`); + } + return res.data; }); } /** @@ -102,7 +176,19 @@ export class Light { */ logout() { return __awaiter(this, void 0, void 0, function* () { - yield this.sendPostRequest("/logout", {}); + yield this.sendPostRequest("/logout"); + }); + } + /** + * Automatically ends a login call after 1 second + */ + autoEndLoginCall() { + return __awaiter(this, void 0, void 0, function* () { + yield delay(1000); + if (this.activeLoginCall) { + this.activeLoginCall = false; + console.warn("Login call timed out"); + } }); } /** @@ -360,78 +446,6 @@ export class Light { yield this.sendPostRequest("/led/mode", { mode: mode }); }); } - /** - * Sends a POST request to the device, appending the required tokens - * - * @param {string} url - * @param {object} params - */ - sendPostRequest(url, data, contentType = "application/json") { - return __awaiter(this, void 0, void 0, function* () { - if (!this.token) - throw errNoToken; - let res; - try { - res = yield this.net.post(url, data, { - headers: { - "Content-Type": contentType, - }, - }); - } - catch (err) { - throw err; - } - if (res.data.code != applicationResponseCode.Ok) { - throw Error(`Mode set failed with error code ${res.data.code}`); - } - return res.data; - }); - } - /** - * - * @param {string} url - * @param {object} data - */ - sendDeleteRequest(url, data) { - return __awaiter(this, void 0, void 0, function* () { - if (!this.token) - throw errNoToken; - let res; - try { - res = yield this.net.delete(url, data); - } - catch (err) { - throw err; - } - if (res.data.code != applicationResponseCode.Ok) { - throw Error(`Mode set failed with error code ${res.data.code}`); - } - return res.data; - }); - } - /** - * Sends a GET request to the device, appending the required tokens - * - * @param {string} url - * @param {object} params - */ - sendGetRequest(url, params, requiresToken = true) { - return __awaiter(this, void 0, void 0, function* () { - if (!this.token && requiresToken) - throw errNoToken; - let res; - try { - res = yield this.net.get(url, params || {}); - } - catch (err) { - throw err; - } - if (res.data.code != applicationResponseCode.Ok) { - throw Error(`Request failed with error code ${res.data.code}`); - } - return res.data; - }); - } /** * Send a movie config to the device * @@ -720,7 +734,7 @@ export class AuthenticationToken { * Creates an instance of AuthenticationToken. * * @constructor - * @param {AxiosResponse} res Response from POST request + * @param {AxiosResponse | FetchResponse} res Response from POST request */ constructor(res) { this.token = res.data.authentication_token; diff --git a/src/lib/fetchwrapper.ts b/src/lib/fetchwrapper.ts new file mode 100644 index 0000000..08b28be --- /dev/null +++ b/src/lib/fetchwrapper.ts @@ -0,0 +1,120 @@ +export default class FetchWrapper { + private baseURL: string; + private timeout: number; + public defaults: FetchDefaults = { + headers: {}, + }; + + constructor(baseURL = "", timeout = 5000) { + this.baseURL = baseURL; + this.timeout = timeout; + } + + addHeaders(newHeaders: Record) { + this.defaults.headers = { ...this.defaults.headers, ...newHeaders }; + } + + async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + this.stringifyBodyIfJson(options); + const url = `${this.baseURL}${endpoint}`; + options.headers = { + ...this.defaults.headers, + ...(options.headers as Record), + }; + + try { + const response = await Promise.race([ + fetch(url, options), + this.createTimeoutPromise(), + ]); + const data = await response.json(); + this.handleResponseError(response, data); + const combinedFetchResponse = { ...response, data }; + return combinedFetchResponse; + } catch (error: any) { + throw error; + } + } + + async get(endpoint: string, options: RequestInit = {}): Promise { + return this.request(endpoint, { ...options, method: "GET" }); + } + + async post( + endpoint: string, + body: any, + options: RequestInit = {} + ): Promise { + this.setHeaderIfJson(options); + return this.request(endpoint, { ...options, method: "POST", body }); + } + + async delete(endpoint: string, data: any): Promise { + return this.request(endpoint, { + method: "DELETE", + body: JSON.stringify(data), + }); + } + + async put( + endpoint: string, + body: any, + options: RequestInit = {} + ): Promise { + this.setHeaderIfJson(options); + return this.request(endpoint, { + ...options, + method: "PUT", + body: JSON.stringify(body), + }); + } + + private stringifyBodyIfJson(options: RequestInit | any): void { + if (!options.headers) { + return; + } + if (options.headers["Content-Type"] === "application/json") { + options.body = JSON.stringify(options.body); + } + } + + private createTimeoutPromise(): Promise { + return new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Request was aborted due to a timeout < ${this.timeout}ms. Increase the timeout to avoid this error.` + ) + ), + this.timeout + ) + ); + } + + private setHeaderIfJson(options: RequestInit | any): void { + if (!options.headers) { + options.headers = {}; + } + if (!options.headers["Content-Type"]) { + options.headers["Content-Type"] = "application/json"; + } + } + + private handleResponseError(response: FetchResponse, data: any): void { + if (!response.ok) { + throw new Error(data.message || "Error fetching data"); + } + } +} + +export interface FetchDefaults { + headers: Record; +} + +export interface FetchResponse extends Response { + data?: any; +} diff --git a/src/lib/light.ts b/src/lib/light.ts index a4739cb..28a7931 100644 --- a/src/lib/light.ts +++ b/src/lib/light.ts @@ -1,6 +1,7 @@ import { generateRandomHex } from "./utils.js"; import axios, { AxiosInstance, AxiosResponse } from "axios"; +import FetchWrapper, { FetchResponse } from "./fetchwrapper.js"; import delay from "delay"; // dynamically import udp for compatibility with browser // import * as udp from "node:dgram"; @@ -30,7 +31,7 @@ let errNoToken = Error("No valid token"); export class Light { ipaddr: string; challenge: string; - net: AxiosInstance; + net: AxiosInstance | FetchWrapper; token: AuthenticationToken | undefined; activeLoginCall: boolean; nleds: number | undefined; @@ -41,13 +42,22 @@ export class Light { * @constructor * @param {string} ipaddr IP Address of the Twinkly device */ - constructor(ipaddr: string, timeout: number = 20000) { + constructor( + ipaddr: string, + timeout: number = 20000, + useFetch: boolean = false + ) { this.ipaddr = ipaddr; this.challenge = ""; // default value, will be set in login() - this.net = axios.create({ + const config = { baseURL: `http://${this.ipaddr}/xled/v1/`, timeout: timeout, - }); + }; + if (useFetch) { + this.net = new FetchWrapper(config.baseURL, config.timeout); + } else { + this.net = axios.create(config); + } this.activeLoginCall = false; // dynamically import udp asynchroniously with IIFE @@ -68,10 +78,78 @@ export class Light { } })(); } - async autoEndLoginCall(): Promise { - await delay(1000); - this.activeLoginCall = false; + /** + * Sends a POST request to the device, appending the required tokens + * + * @param {string} url + * @param {object} params + */ + async sendPostRequest( + url: string, + data: any = {}, + contentType: string = "application/json" + ): Promise { + if (!this.token) throw errNoToken; + let res: AxiosResponse | FetchResponse; + try { + res = await this.net.post(url, data, { + headers: { + "Content-Type": contentType, + }, + }); + } catch (err) { + throw err; + } + if (res.data.code != applicationResponseCode.Ok) { + throw Error(`Mode set failed with error code ${res.data.code}`); + } + return res.data; } + + /** + * Sends a DELETE request to the device, appending the required tokens + * + * @param {string} url + * @param {object} data + */ + async sendDeleteRequest(url: string, data: any): Promise { + if (!this.token) throw errNoToken; + let res: AxiosResponse | FetchResponse; + try { + res = await this.net.delete(url, data); + } catch (err) { + throw err; + } + if (res.data.code != applicationResponseCode.Ok) { + throw Error(`Mode set failed with error code ${res.data.code}`); + } + return res.data; + } + + /** + * Sends a GET request to the device, appending the required tokens + * + * @param {string} url + * @param {object} params + */ + async sendGetRequest( + url: string, + params?: object, + requiresToken: boolean = true + ): Promise { + if (!this.token && requiresToken) throw errNoToken; + let res: AxiosResponse | FetchResponse; + try { + res = await this.net.get(url, params || {}); + } catch (err) { + throw err; + } + if (res.data.code != applicationResponseCode.Ok) { + throw Error(`Request failed with error code ${res.data.code}`); + } + return res.data; + } + /** * Sends a login request * @@ -80,7 +158,7 @@ export class Light { async login(): Promise { this.activeLoginCall = true; this.autoEndLoginCall(); - let res: AxiosResponse; + let res: AxiosResponse | FetchResponse; this.challenge = await generateRandomHex(256); try { @@ -107,13 +185,23 @@ export class Light { * Sends a logout request */ async logout(): Promise { - await this.sendPostRequest("/logout", {}); + await this.sendPostRequest("/logout"); + } + /** + * Automatically ends a login call after 1 second + */ + async autoEndLoginCall(): Promise { + await delay(1000); + if (this.activeLoginCall) { + this.activeLoginCall = false; + console.warn("Login call timed out"); + } } /** * Check that we are logged in to the device */ async verify(): Promise { - let res: AxiosResponse; + let res: AxiosResponse | FetchResponse; if (this.token === undefined) throw errNoToken; try { res = await this.net.post("/verify", { @@ -341,77 +429,6 @@ export class Light { async setMode(mode: deviceMode): Promise { await this.sendPostRequest("/led/mode", { mode: mode }); } - - /** - * Sends a POST request to the device, appending the required tokens - * - * @param {string} url - * @param {object} params - */ - async sendPostRequest( - url: string, - data: any, - contentType: string = "application/json" - ): Promise { - if (!this.token) throw errNoToken; - let res: AxiosResponse; - try { - res = await this.net.post(url, data, { - headers: { - "Content-Type": contentType, - }, - }); - } catch (err) { - throw err; - } - if (res.data.code != applicationResponseCode.Ok) { - throw Error(`Mode set failed with error code ${res.data.code}`); - } - return res.data; - } - - /** - * - * @param {string} url - * @param {object} data - */ - async sendDeleteRequest(url: string, data: any): Promise { - if (!this.token) throw errNoToken; - let res: AxiosResponse; - try { - res = await this.net.delete(url, data); - } catch (err) { - throw err; - } - if (res.data.code != applicationResponseCode.Ok) { - throw Error(`Mode set failed with error code ${res.data.code}`); - } - return res.data; - } - - /** - * Sends a GET request to the device, appending the required tokens - * - * @param {string} url - * @param {object} params - */ - async sendGetRequest( - url: string, - params?: object, - requiresToken: boolean = true - ): Promise { - if (!this.token && requiresToken) throw errNoToken; - let res: AxiosResponse; - try { - res = await this.net.get(url, params || {}); - } catch (err) { - throw err; - } - if (res.data.code != applicationResponseCode.Ok) { - throw Error(`Request failed with error code ${res.data.code}`); - } - return res.data; - } /** * Send a movie config to the device * @@ -688,9 +705,9 @@ export class AuthenticationToken { * Creates an instance of AuthenticationToken. * * @constructor - * @param {AxiosResponse} res Response from POST request + * @param {AxiosResponse | FetchResponse} res Response from POST request */ - constructor(res: AxiosResponse) { + constructor(res: AxiosResponse | FetchResponse) { this.token = res.data.authentication_token; this.expiry = new Date( Date.now() + res.data.authentication_token_expires_in * 1000