From d4ff648181072b1e4beaed39d10d8c30de07130a Mon Sep 17 00:00:00 2001 From: Liam Dyer Date: Wed, 31 Jan 2024 07:24:34 -0500 Subject: [PATCH] feat: migration from axios to fetch (#42) * feat: migration from axios to fetch * feat: timeout, additional typing, always return json on get --- README.md | 4 +- package-lock.json | 186 ++------------------- package.json | 3 - src/aw-client.ts | 400 +++++++++++++++++++++++++++------------------- src/test/test.ts | 12 +- tsconfig.json | 24 +-- 6 files changed, 268 insertions(+), 361 deletions(-) diff --git a/README.md b/README.md index c3c6752..857f3e2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ npm install aw-client ## Usage -The library uses Promises for almost everything, so either use `.then()` or the (recommended) async/aways syntax. +The library uses Promises for almost everything, so either use `.then()` or async/await syntax. The example below is written with `.then()` to make it easy to run in the node REPL. @@ -55,5 +55,5 @@ npm run compile ### Run the tests ```sh -npm run test +npm test ``` diff --git a/package-lock.json b/package-lock.json index 1386000..71d8df2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,13 @@ { "name": "aw-client", - "version": "0.3.5", + "version": "0.3.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "aw-client", - "version": "0.3.5", + "version": "0.3.7", "license": "MPL-2.0", - "dependencies": { - "axios": "*" - }, "devDependencies": { "@types/mocha": "*", "@types/node": "*", @@ -190,12 +187,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.8.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", - "integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", + "version": "20.11.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.10.tgz", + "integrity": "sha512-rZEfe/hJSGYmdfX9tvcPMYeYPW2sNl50nsw4jZmRcaG0HIAb0WYEpsB05GOb53vjqpyE9GUhlDQ4jLSoB5q9kg==", "dev": true, "dependencies": { - "undici-types": "~5.25.1" + "undici-types": "~5.26.4" } }, "node_modules/@types/semver": { @@ -490,21 +487,6 @@ "node": ">=8" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -689,17 +671,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -801,14 +772,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -1232,38 +1195,6 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1743,25 +1674,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -2125,11 +2037,6 @@ "node": ">=6.0.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -2609,9 +2516,9 @@ "dev": true }, "node_modules/undici-types": { - "version": "5.25.3", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", - "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, "node_modules/untildify": { @@ -2867,12 +2774,12 @@ "dev": true }, "@types/node": { - "version": "20.8.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", - "integrity": "sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==", + "version": "20.11.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.10.tgz", + "integrity": "sha512-rZEfe/hJSGYmdfX9tvcPMYeYPW2sNl50nsw4jZmRcaG0HIAb0WYEpsB05GOb53vjqpyE9GUhlDQ4jLSoB5q9kg==", "dev": true, "requires": { - "undici-types": "~5.25.1" + "undici-types": "~5.26.4" } }, "@types/semver": { @@ -3048,21 +2955,6 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", - "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3199,14 +3091,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3273,11 +3157,6 @@ "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, "diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -3591,21 +3470,6 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, - "follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" - }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3939,19 +3803,6 @@ "picomatch": "^2.3.1" } }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, "mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -4206,11 +4057,6 @@ "fast-diff": "^1.1.2" } }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -4521,9 +4367,9 @@ "dev": true }, "undici-types": { - "version": "5.25.3", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", - "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true }, "untildify": { diff --git a/package.json b/package.json index 00ee7e2..0f2ddb1 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,6 @@ "files": [ "out" ], - "dependencies": { - "axios": "*" - }, "devDependencies": { "@types/mocha": "*", "@types/node": "*", diff --git a/src/aw-client.ts b/src/aw-client.ts index 2031aab..4a500f8 100644 --- a/src/aw-client.ts +++ b/src/aw-client.ts @@ -1,9 +1,25 @@ -import axios, { AxiosError, AxiosInstance } from "axios"; +export class FetchError extends Error { + response: Response; + constructor(res: Response) { + super(`Failed fetch with status code ${res.status}`); + this.response = res; + } +} type EventData = { [k: string]: string | number }; -type JSONable = object | string | number | boolean; + +type JSONPrimitive = string | number | boolean | null; +type JSONValue = JSONPrimitive | JSONObject | JSONArray; +type JSONArray = Array; +type JSONObject = { [member: string]: JSONValue }; // Default interface for events +interface IEventRaw { + id?: number; + timestamp: string; + duration?: number; + data: EventData; +} export interface IEvent { id?: number; timestamp: Date; @@ -27,6 +43,16 @@ export interface AWReqOptions { timeout?: number; } +interface IBucketRaw { + id: string; + name: string; + type: string; + client: string; + hostname: string; + created: string; + last_update?: string; + data: Record; +} export interface IBucket { id: string; name: string; @@ -40,7 +66,7 @@ export interface IBucket { interface IHeartbeatQueueItem { onSuccess: (value?: PromiseLike | undefined) => void; - onError: (err: AxiosError) => void; + onError: (err: Error) => void; pulsetime: number; heartbeat: IEvent; } @@ -57,11 +83,46 @@ interface GetEventsOptions { limit?: number; } +function makeTimeoutAbortSignal( + timeout?: number, + existingSignal?: AbortSignal +) { + if (timeout === undefined) + return { signal: existingSignal, timeoutId: undefined }; + const abortController = new AbortController(); + const timeoutId = setTimeout( + () => abortController.abort(), + timeout || 10000 + ); + // Sync with existing abort signal if it exists + if (existingSignal?.aborted) abortController.abort(); + else + existingSignal?.addEventListener("abort", () => + abortController.abort() + ); + return { signal: abortController.signal, timeoutId }; +} + +async function fetchWithFailure( + input: string, + init: RequestInit, + timeout?: number +): Promise { + const { signal, timeoutId } = makeTimeoutAbortSignal(timeout, init.signal); + return fetch(input, { ...init, signal }) + .then((res) => { + if (res.status >= 300) throw new FetchError(res); + return res; + }) + .finally(() => clearTimeout(timeoutId)); +} + export class AWClient { public clientname: string; public baseURL: string; + public apiURL: string; + public timeout: number; public testing: boolean; - public req: AxiosInstance; public controller: AbortController; @@ -75,7 +136,8 @@ export class AWClient { constructor(clientname: string, options: AWReqOptions = {}) { this.clientname = clientname; - this.testing = options.testing || false; + this.testing = options.testing ?? false; + this.timeout = options.timeout ?? 30000; if (typeof options.baseURL === "undefined") { const port = !options.testing ? 5600 : 5666; // Note: had to switch to 127.0.0.1 over localhost as otherwise there's @@ -84,36 +146,53 @@ export class AWClient { } else { this.baseURL = options.baseURL; } + this.apiURL = this.baseURL + "/api"; this.controller = options.controller || new AbortController(); - this.req = axios.create({ - baseURL: this.baseURL + "/api", - timeout: options.timeout || 30000, - }); - // Cache for queries, by timeperiod // TODO: persist cache and add cache expiry/invalidation this.queryCache = {}; } - private async _get(endpoint: string, params: object = {}) { - return this.req - .get(endpoint, { ...params, signal: this.controller.signal }) - .then((res) => (res && res.data) || res); + /// Fetching logic + /** Makes a GET request, assuming the response is JSON and parsing it */ + private async _get(endpoint: string, params: RequestInit = {}) { + return fetchWithFailure( + `${this.apiURL}${endpoint}`, + { + ...params, + signal: this.controller.signal, + }, + this.timeout + ).then((res) => res.json() as Promise); } - private async _post(endpoint: string, data: JSONable = {}) { - return this.req - .post(endpoint, data, { signal: this.controller.signal }) - .then((res) => (res && res.data) || res); + private async _post(endpoint: string, data: Record) { + return fetchWithFailure( + `${this.apiURL}${endpoint}`, + { + method: "POST", + signal: this.controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }, + this.timeout + ); } private async _delete(endpoint: string) { - return this.req.delete(endpoint, { signal: this.controller.signal }); + return fetchWithFailure( + `${this.apiURL}${endpoint}`, + { + method: "DELETE", + signal: this.controller.signal, + }, + this.timeout + ); } public async getInfo(): Promise { - return this._get("/0/info"); + return this._get("/0/info"); } public async abort(msg?: string) { @@ -122,119 +201,125 @@ export class AWClient { this.controller = new AbortController(); } + /// Buckets + private processRawBucket(bucket: IBucketRaw): IBucket { + return { + ...bucket, + created: new Date(bucket.created), + last_update: + bucket.last_update !== undefined + ? new Date(bucket.last_update) + : undefined, + }; + } + public async ensureBucket( bucketId: string, type: string, - hostname: string, + hostname: string ): Promise<{ alreadyExist: boolean }> { - try { - await this._post(`/0/buckets/${bucketId}`, { - client: this.clientname, - type, - hostname, + return this._post(`/0/buckets/${bucketId}`, { + client: this.clientname, + type, + hostname, + }) + .then(() => ({ alreadyExist: false })) + .catch((err) => { + // Will return 304 if bucket already exists + if (err instanceof FetchError && err.response.status === 304) { + return { alreadyExist: true }; + } + throw err; }); - } catch (err) { - // Will return 304 if bucket already exists - if ( - axios.isAxiosError(err) && - err.response && - err.response.status === 304 - ) { - return { alreadyExist: true }; - } - throw err; - } - return { alreadyExist: false }; } public async createBucket( bucketId: string, type: string, - hostname: string, - ): Promise { + hostname: string + ): Promise { await this._post(`/0/buckets/${bucketId}`, { client: this.clientname, type, hostname, }); - return undefined; } - public async deleteBucket(bucketId: string): Promise { + public async deleteBucket(bucketId: string): Promise { await this._delete(`/0/buckets/${bucketId}?force=1`); - return undefined; } public async getBuckets(): Promise<{ [bucketId: string]: IBucket }> { - const buckets = await this._get("/0/buckets/"); - Object.keys(buckets).forEach((bucket) => { - buckets[bucket].created = new Date(buckets[bucket].created); - if (buckets[bucket].last_updated) { - buckets[bucket].last_updated = new Date( - buckets[bucket].last_updated, - ); - } - }); + const rawBuckets = await this._get<{ [bucketId: string]: IBucketRaw }>( + "/0/buckets/" + ); + const buckets: { [bucketId: string]: IBucket } = {}; + for (const bucketId of Object.keys(rawBuckets)) { + buckets[bucketId] = this.processRawBucket(rawBuckets[bucketId]); + } return buckets; } public async getBucketInfo(bucketId: string): Promise { - const bucket = await this._get(`/0/buckets/${bucketId}`); + const bucket = await this._get(`/0/buckets/${bucketId}`); if (bucket.data === undefined) { console.warn( - "Received bucket had undefined data, likely due to data field unsupported by server. Try updating your ActivityWatch server to get rid of this message.", + "Received bucket had undefined data, likely due to data field unsupported by server. Try updating your ActivityWatch server to get rid of this message." ); bucket.data = {}; } - bucket.created = new Date(bucket.created); - return bucket; + return this.processRawBucket(bucket); } + /// Events + private processRawEvent(event: IEventRaw): IEvent { + return { ...event, timestamp: new Date(event.timestamp) }; + } + + /** Get a single event by ID */ public async getEvent(bucketId: string, eventId: number): Promise { - // Get a single event by ID - const event = await this._get( - "/0/buckets/" + bucketId + "/events/" + eventId, - ); - event.timestamp = new Date(event.timestamp); - return event; + return this._get( + `/0/buckets/${bucketId}/events/${eventId}` + ).then(this.processRawEvent); } + /** Get events, with optional date ranges and limit */ public async getEvents( bucketId: string, - params: GetEventsOptions = {}, + params: GetEventsOptions = {} ): Promise { - const events = await this._get("/0/buckets/" + bucketId + "/events", { - params, - }); - events.forEach((event: IEvent) => { - event.timestamp = new Date(event.timestamp); - }); - return events; + const searchParams = new URLSearchParams(); + if (params.start) searchParams.set("start", params.start.toISOString()); + if (params.end) searchParams.set("end", params.end.toISOString()); + if (params.limit) searchParams.set("limit", params.limit.toString()); + const url = `/0/buckets/${bucketId}/events?${searchParams.toString()}`; + return this._get(url).then((events) => + events.map(this.processRawEvent) + ); } + /** Count the number of events, with optional date ranges */ public async countEvents( bucketId: string, startTime?: Date, - endTime?: Date, + endTime?: Date ) { - const params = { - starttime: startTime ? startTime.toISOString() : null, - endtime: endTime ? endTime.toISOString() : null, - }; - return this._get("/0/buckets/" + bucketId + "/events/count", { - params, - }); + const params = new URLSearchParams(); + if (startTime) params.set("starttime", startTime.toISOString()); + if (endTime) params.set("endtime", endTime.toISOString()); + const url = `/0/buckets/${bucketId}/events/count?${params.toString()}`; + return this._get(url); } - // Insert a single event, requires the event to not have an ID assigned + /** Insert a single event, requires the event to not have an ID assigned */ public async insertEvent(bucketId: string, event: IEvent): Promise { await this.insertEvents(bucketId, [event]); } - // Insert multiple events, requires the events to not have IDs assigned + /** Insert multiple events, requires the events to not have IDs assigned */ public async insertEvents( bucketId: string, - events: IEvent[], + events: IEvent[] ): Promise { // Check that events don't have IDs // To replace an event, use `replaceEvent`, which does the opposite check (requires ID) @@ -246,15 +331,15 @@ export class AWClient { await this._post("/0/buckets/" + bucketId + "/events", events); } - // Replace an event, requires the event to have an ID assigned + /** Replace an event, requires the event to have an ID assigned */ public async replaceEvent(bucketId: string, event: IEvent): Promise { await this.replaceEvents(bucketId, [event]); } - // Replace multiple events, requires the events to have IDs assigned + /** Replace multiple events, requires the events to have IDs assigned */ public async replaceEvents( bucketId: string, - events: IEvent[], + events: IEvent[] ): Promise { for (const event of events) { if (event.id === undefined) { @@ -264,12 +349,12 @@ export class AWClient { await this._post("/0/buckets/" + bucketId + "/events", events); } + /** Delete an event by ID */ public async deleteEvent(bucketId: string, eventId: number): Promise { await this._delete("/0/buckets/" + bucketId + "/events/" + eventId); } /** - * * @param bucketId The id of the bucket to send the heartbeat to * @param pulsetime The maximum amount of time in seconds since the last heartbeat to be merged * with the previous heartbeat in aw-server @@ -278,20 +363,13 @@ export class AWClient { public heartbeat( bucketId: string, pulsetime: number, - heartbeat: IEvent, + heartbeat: IEvent ): Promise { // Create heartbeat queue for bucket if not already existing - if ( - !Object.prototype.hasOwnProperty.call( - this.heartbeatQueues, - bucketId, - ) - ) { - this.heartbeatQueues[bucketId] = { - isProcessing: false, - data: [], - }; - } + this.heartbeatQueues[bucketId] ??= { + isProcessing: false, + data: [], + }; return new Promise((resolve, reject) => { // Add heartbeat request to queue @@ -321,7 +399,7 @@ export class AWClient { cacheEmpty?: boolean; verbose?: boolean; name?: string; - } = {}, + } = {} ): Promise { params.cache = params.cache ?? true; params.cacheEmpty = params.cacheEmpty ?? false; @@ -335,11 +413,11 @@ export class AWClient { const data = { query, - timeperiods: timeperiods.map((tp) => { - return typeof tp !== "string" + timeperiods: timeperiods.map((tp) => + typeof tp !== "string" ? `${tp.start.toISOString()}/${tp.end.toISOString()}` - : tp; - }), + : tp + ), }; const cacheResults: any[] = []; @@ -370,14 +448,14 @@ export class AWClient { if (cacheResults.every((r) => r !== null)) { if (params.verbose) console.debug( - `Returning fully cached query results for ${params.name}`, + `Returning fully cached query results for ${params.name}` ); return cacheResults; } } const timeperiodsNotCached = data.timeperiods.filter( - (_, i) => cacheResults[i] === null, + (_, i) => cacheResults[i] === null ); // Otherwise, query with remaining timeperiods @@ -386,84 +464,82 @@ export class AWClient { ? await this._post("/0/query/", { ...data, timeperiods: timeperiodsNotCached, - }) + }).then((res) => res.json() as Promise) : []; - if (params.cache) { - if (params.verbose) { - if (cacheResults.every((r) => r === null)) { - console.debug( - `Returning uncached query results for ${params.name}`, - ); - } else if ( - cacheResults.some((r) => r === null) && - cacheResults.some((r) => r !== null) - ) { - console.debug( - `Returning partially cached query results for ${params.name}`, - ); - } - } + if (!params.cache) return queryResults; - // Cache results - // NOTE: this also caches timeperiods that span the future, - // but this is ok since we check that when first checking the cache, - // and makes it easier to return all results from cache. - for (const [i, result] of queryResults.entries()) { - const cacheKey = JSON.stringify({ - timeperiod: timeperiodsNotCached[i], - query, - }); - this.queryCache[cacheKey] = result; + if (params.verbose) { + if (cacheResults.every((r) => r === null)) { + console.debug( + `Returning uncached query results for ${params.name}` + ); + } else if ( + cacheResults.some((r) => r === null) && + cacheResults.some((r) => r !== null) + ) { + console.debug( + `Returning partially cached query results for ${params.name}` + ); } + } - // Return all results from cache - return data.timeperiods.map((tp) => { - const cacheKey = JSON.stringify({ - timeperiod: tp, - query, - }); - return this.queryCache[cacheKey]; + // Cache results + // NOTE: this also caches timeperiods that span the future, + // but this is ok since we check that when first checking the cache, + // and makes it easier to return all results from cache. + for (const [i, result] of queryResults.entries()) { + const cacheKey = JSON.stringify({ + timeperiod: timeperiodsNotCached[i], + query, }); - } else { - return queryResults; + this.queryCache[cacheKey] = result; } + + // Return all results from cache + return data.timeperiods.map((tp) => { + const cacheKey = JSON.stringify({ + timeperiod: tp, + query, + }); + return this.queryCache[cacheKey]; + }); } - /* eslint-enable @typescript-eslint/no-explicit-any */ private async send_heartbeat( bucketId: string, pulsetime: number, - data: IEvent, + data: IEvent ): Promise { const url = "/0/buckets/" + bucketId + "/heartbeat?pulsetime=" + pulsetime; - const heartbeat = await this._post(url, data); + const heartbeat = await this._post(url, data).then( + (res) => res.json() as Promise + ); heartbeat.timestamp = new Date(heartbeat.timestamp); return heartbeat; } - // Start heartbeat queue processing if not currently processing + /** Start heartbeat queue processing if not currently processing */ private updateHeartbeatQueue(bucketId: string) { const queue = this.heartbeatQueues[bucketId]; - if (!queue.isProcessing && queue.data.length) { - const { pulsetime, heartbeat, onSuccess, onError } = - queue.data.shift() as IHeartbeatQueueItem; - - queue.isProcessing = true; - this.send_heartbeat(bucketId, pulsetime, heartbeat) - .then(() => { - onSuccess(); - queue.isProcessing = false; - this.updateHeartbeatQueue(bucketId); - }) - .catch((err) => { - onError(err); - queue.isProcessing = false; - this.updateHeartbeatQueue(bucketId); - }); - } + if (queue.isProcessing || !queue.data.length) return; + const { pulsetime, heartbeat, onSuccess, onError } = + queue.data.shift() as IHeartbeatQueueItem; + + queue.isProcessing = true; + this.send_heartbeat(bucketId, pulsetime, heartbeat) + .then(() => { + onSuccess(); + queue.isProcessing = false; + this.updateHeartbeatQueue(bucketId); + }) + .catch((err) => { + onError(err); + queue.isProcessing = false; + this.updateHeartbeatQueue(bucketId); + }); } // Get all settings @@ -472,12 +548,12 @@ export class AWClient { } // Get a setting - public async get_setting(key: string): Promise { + public async get_setting(key: string): Promise { return await this._get("/0/settings/" + key); } // Set a setting - public async set_setting(key: string, value: JSONable): Promise { + public async set_setting(key: string, value: JSONObject): Promise { await this._post("/0/settings/" + key, value); } } diff --git a/src/test/test.ts b/src/test/test.ts index 1cca091..ad5976d 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -1,9 +1,9 @@ import * as assert from "assert"; -import { AxiosError } from "axios"; +import { FetchError } from "../aw-client"; import { AWClient, IEvent } from "../aw-client"; -function isAxiosError(error: unknown): error is AxiosError { - return (error as AxiosError).isAxiosError; +function isFetchError(error: unknown): error is FetchError { + return error instanceof FetchError } // Bucket info @@ -31,7 +31,7 @@ describe("Basic API usage", () => { try { return await awc.deleteBucket(bucketId); } catch (err) { - if (isAxiosError(err)) { + if (isFetchError(err)) { if (err.response?.status === 404) { return; } @@ -96,7 +96,7 @@ describe("Basic API usage", () => { }); assert.fail("Should have thrown error"); } catch (err) { - if (isAxiosError(err)) { + if (isFetchError(err)) { throw err; } } @@ -111,7 +111,7 @@ describe("Basic API usage", () => { }); assert.fail("Should have thrown error"); } catch (err) { - if (isAxiosError(err)) { + if (isFetchError(err)) { throw err; } } diff --git a/tsconfig.json b/tsconfig.json index 73d75f9..c1b0c2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,26 +1,14 @@ { "compilerOptions": { + "strict": true, + "noUnusedLocals": true, "module": "commonjs", "target": "es6", + "lib": ["es6"], + // Outputs "outDir": "out", - "lib": [ - "es6" - ], "sourceMap": true, - "declaration": true, - "rootDir": "src", - "typeRoots": ["./node_modules/@types"], - /* Strict Type-Checking Option */ - "strict": true, /* enable all strict type-checking options */ - /* Additional Checks */ - "noUnusedLocals": true /* Report errors on unused locals. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "declaration": true }, - "exclude": [ - "node_modules", - ".vscode-test", - "out" - ] + "include": ["src"] }