@dlwiest/ts-tonal-client 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/dist/index.esm.js +69 -5
- package/dist/index.js +69 -5
- package/dist/src/auth/auth-manager.d.ts +4 -1
- package/package.json +14 -3
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# TypeScript Tonal Client
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client)
|
|
4
|
+
[](https://www.npmjs.com/package/@dlwiest/ts-tonal-client)
|
|
5
|
+
|
|
3
6
|
A comprehensive TypeScript client for accessing Tonal's API. This library provides a robust interface to retrieve workout data, user information, movements, and more from your Tonal account.
|
|
4
7
|
|
|
5
8
|
## Features
|
|
@@ -17,12 +20,19 @@ A comprehensive TypeScript client for accessing Tonal's API. This library provid
|
|
|
17
20
|
|
|
18
21
|
## Installation
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
### Option 1: Install from npm (Recommended)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @dlwiest/ts-tonal-client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Option 2: Clone and build from source
|
|
21
30
|
|
|
22
31
|
```bash
|
|
23
32
|
git clone https://github.com/dlwiest/ts-tonal-client.git
|
|
24
33
|
cd ts-tonal-client
|
|
25
34
|
npm install
|
|
35
|
+
npm run build
|
|
26
36
|
```
|
|
27
37
|
|
|
28
38
|
## Quick Start
|
|
@@ -30,7 +40,7 @@ npm install
|
|
|
30
40
|
Basic usage:
|
|
31
41
|
|
|
32
42
|
```typescript
|
|
33
|
-
import TonalClient from '
|
|
43
|
+
import TonalClient from '@dlwiest/ts-tonal-client'
|
|
34
44
|
|
|
35
45
|
const client = await TonalClient.create({
|
|
36
46
|
username: 'your_email@example.com',
|
package/dist/index.esm.js
CHANGED
|
@@ -10,7 +10,9 @@ class TonalClientError extends Error {
|
|
|
10
10
|
class AuthManager {
|
|
11
11
|
constructor(username, password) {
|
|
12
12
|
this.idToken = '';
|
|
13
|
+
this.refreshToken = '';
|
|
13
14
|
this.tokenExpiresAt = 0;
|
|
15
|
+
this.isRefreshing = false;
|
|
14
16
|
this.authUrl = 'https://tonal.auth0.com/oauth/token';
|
|
15
17
|
this.clientId = 'ERCyexW-xoVG_Yy3RDe-eV4xsOnRHP6L';
|
|
16
18
|
this.username = username;
|
|
@@ -46,18 +48,70 @@ class AuthManager {
|
|
|
46
48
|
}
|
|
47
49
|
const tokenData = await response.json();
|
|
48
50
|
this.idToken = tokenData.id_token;
|
|
51
|
+
this.refreshToken = tokenData.refresh_token;
|
|
49
52
|
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
50
53
|
return this.idToken;
|
|
51
54
|
}
|
|
52
|
-
getValidToken() {
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
+
async getValidToken() {
|
|
56
|
+
if (this.isTokenValid()) {
|
|
57
|
+
return this.idToken;
|
|
55
58
|
}
|
|
56
|
-
|
|
59
|
+
if (this.refreshToken) {
|
|
60
|
+
if (!this.isRefreshing) {
|
|
61
|
+
await this.refreshTokens();
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// Wait for ongoing refresh to complete
|
|
65
|
+
while (this.isRefreshing) {
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (this.isTokenValid()) {
|
|
70
|
+
return this.idToken;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new TonalClientError('Token expired and refresh failed. Call authenticate() first.');
|
|
57
74
|
}
|
|
58
75
|
isTokenValid() {
|
|
59
76
|
return !!this.idToken && Date.now() < this.tokenExpiresAt - 60000; // 1 minute buffer
|
|
60
77
|
}
|
|
78
|
+
async refreshTokens() {
|
|
79
|
+
if (this.isRefreshing) {
|
|
80
|
+
return; // Prevent concurrent refresh attempts
|
|
81
|
+
}
|
|
82
|
+
this.isRefreshing = true;
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(this.authUrl, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
client_id: this.clientId,
|
|
91
|
+
grant_type: 'refresh_token',
|
|
92
|
+
refresh_token: this.refreshToken,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const errorText = await response.text();
|
|
97
|
+
let errorData;
|
|
98
|
+
try {
|
|
99
|
+
errorData = JSON.parse(errorText);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
errorData = { error: errorText };
|
|
103
|
+
}
|
|
104
|
+
throw new TonalClientError(errorData.error_description || errorData.error || 'Token refresh failed', response.status, errorData);
|
|
105
|
+
}
|
|
106
|
+
const tokenData = await response.json();
|
|
107
|
+
this.idToken = tokenData.id_token;
|
|
108
|
+
this.refreshToken = tokenData.refresh_token;
|
|
109
|
+
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
this.isRefreshing = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
61
115
|
}
|
|
62
116
|
|
|
63
117
|
class HttpClient {
|
|
@@ -75,7 +129,7 @@ class HttpClient {
|
|
|
75
129
|
const controller = new AbortController();
|
|
76
130
|
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
77
131
|
try {
|
|
78
|
-
const token = this.authManager.getValidToken();
|
|
132
|
+
const token = await this.authManager.getValidToken();
|
|
79
133
|
const response = await fetch(url, {
|
|
80
134
|
...options,
|
|
81
135
|
headers: {
|
|
@@ -123,6 +177,16 @@ class HttpClient {
|
|
|
123
177
|
}
|
|
124
178
|
catch (error) {
|
|
125
179
|
lastError = error instanceof TonalClientError ? error : new TonalClientError('Unknown error', undefined, error);
|
|
180
|
+
// If it's an auth error on first attempt, try to refresh token and retry once
|
|
181
|
+
if (attempt === 1 && lastError.statusCode && (lastError.statusCode === 401 || lastError.statusCode === 403)) {
|
|
182
|
+
try {
|
|
183
|
+
await this.authManager.getValidToken(); // This will refresh if needed
|
|
184
|
+
continue; // Retry the request with the new token
|
|
185
|
+
}
|
|
186
|
+
catch (refreshError) {
|
|
187
|
+
// If refresh fails, continue with normal retry logic
|
|
188
|
+
}
|
|
189
|
+
}
|
|
126
190
|
if (attempt === this.maxRetries || (lastError.statusCode && lastError.statusCode < 500)) {
|
|
127
191
|
throw lastError;
|
|
128
192
|
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,9 @@ class TonalClientError extends Error {
|
|
|
14
14
|
class AuthManager {
|
|
15
15
|
constructor(username, password) {
|
|
16
16
|
this.idToken = '';
|
|
17
|
+
this.refreshToken = '';
|
|
17
18
|
this.tokenExpiresAt = 0;
|
|
19
|
+
this.isRefreshing = false;
|
|
18
20
|
this.authUrl = 'https://tonal.auth0.com/oauth/token';
|
|
19
21
|
this.clientId = 'ERCyexW-xoVG_Yy3RDe-eV4xsOnRHP6L';
|
|
20
22
|
this.username = username;
|
|
@@ -50,18 +52,70 @@ class AuthManager {
|
|
|
50
52
|
}
|
|
51
53
|
const tokenData = await response.json();
|
|
52
54
|
this.idToken = tokenData.id_token;
|
|
55
|
+
this.refreshToken = tokenData.refresh_token;
|
|
53
56
|
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
54
57
|
return this.idToken;
|
|
55
58
|
}
|
|
56
|
-
getValidToken() {
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
+
async getValidToken() {
|
|
60
|
+
if (this.isTokenValid()) {
|
|
61
|
+
return this.idToken;
|
|
59
62
|
}
|
|
60
|
-
|
|
63
|
+
if (this.refreshToken) {
|
|
64
|
+
if (!this.isRefreshing) {
|
|
65
|
+
await this.refreshTokens();
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Wait for ongoing refresh to complete
|
|
69
|
+
while (this.isRefreshing) {
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (this.isTokenValid()) {
|
|
74
|
+
return this.idToken;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new TonalClientError('Token expired and refresh failed. Call authenticate() first.');
|
|
61
78
|
}
|
|
62
79
|
isTokenValid() {
|
|
63
80
|
return !!this.idToken && Date.now() < this.tokenExpiresAt - 60000; // 1 minute buffer
|
|
64
81
|
}
|
|
82
|
+
async refreshTokens() {
|
|
83
|
+
if (this.isRefreshing) {
|
|
84
|
+
return; // Prevent concurrent refresh attempts
|
|
85
|
+
}
|
|
86
|
+
this.isRefreshing = true;
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(this.authUrl, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
client_id: this.clientId,
|
|
95
|
+
grant_type: 'refresh_token',
|
|
96
|
+
refresh_token: this.refreshToken,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const errorText = await response.text();
|
|
101
|
+
let errorData;
|
|
102
|
+
try {
|
|
103
|
+
errorData = JSON.parse(errorText);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
errorData = { error: errorText };
|
|
107
|
+
}
|
|
108
|
+
throw new TonalClientError(errorData.error_description || errorData.error || 'Token refresh failed', response.status, errorData);
|
|
109
|
+
}
|
|
110
|
+
const tokenData = await response.json();
|
|
111
|
+
this.idToken = tokenData.id_token;
|
|
112
|
+
this.refreshToken = tokenData.refresh_token;
|
|
113
|
+
this.tokenExpiresAt = Date.now() + (tokenData.expires_in * 1000);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
this.isRefreshing = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
65
119
|
}
|
|
66
120
|
|
|
67
121
|
class HttpClient {
|
|
@@ -79,7 +133,7 @@ class HttpClient {
|
|
|
79
133
|
const controller = new AbortController();
|
|
80
134
|
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
81
135
|
try {
|
|
82
|
-
const token = this.authManager.getValidToken();
|
|
136
|
+
const token = await this.authManager.getValidToken();
|
|
83
137
|
const response = await fetch(url, {
|
|
84
138
|
...options,
|
|
85
139
|
headers: {
|
|
@@ -127,6 +181,16 @@ class HttpClient {
|
|
|
127
181
|
}
|
|
128
182
|
catch (error) {
|
|
129
183
|
lastError = error instanceof TonalClientError ? error : new TonalClientError('Unknown error', undefined, error);
|
|
184
|
+
// If it's an auth error on first attempt, try to refresh token and retry once
|
|
185
|
+
if (attempt === 1 && lastError.statusCode && (lastError.statusCode === 401 || lastError.statusCode === 403)) {
|
|
186
|
+
try {
|
|
187
|
+
await this.authManager.getValidToken(); // This will refresh if needed
|
|
188
|
+
continue; // Retry the request with the new token
|
|
189
|
+
}
|
|
190
|
+
catch (refreshError) {
|
|
191
|
+
// If refresh fails, continue with normal retry logic
|
|
192
|
+
}
|
|
193
|
+
}
|
|
130
194
|
if (attempt === this.maxRetries || (lastError.statusCode && lastError.statusCode < 500)) {
|
|
131
195
|
throw lastError;
|
|
132
196
|
}
|
|
@@ -2,11 +2,14 @@ export declare class AuthManager {
|
|
|
2
2
|
private username;
|
|
3
3
|
private password;
|
|
4
4
|
private idToken;
|
|
5
|
+
private refreshToken;
|
|
5
6
|
private tokenExpiresAt;
|
|
7
|
+
private isRefreshing;
|
|
6
8
|
private readonly authUrl;
|
|
7
9
|
private readonly clientId;
|
|
8
10
|
constructor(username: string, password: string);
|
|
9
11
|
authenticate(): Promise<string>;
|
|
10
|
-
getValidToken(): string
|
|
12
|
+
getValidToken(): Promise<string>;
|
|
11
13
|
private isTokenValid;
|
|
14
|
+
private refreshTokens;
|
|
12
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dlwiest/ts-tonal-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "TypeScript client for Tonal API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
"build": "rollup -c --bundleConfigAsCjs",
|
|
21
21
|
"typecheck": "tsc --noEmit",
|
|
22
22
|
"dev": "tsc --watch --noEmit",
|
|
23
|
-
"
|
|
23
|
+
"test": "jest",
|
|
24
|
+
"test:watch": "jest --watch",
|
|
25
|
+
"test:coverage": "jest --coverage",
|
|
26
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
24
27
|
"example:movements": "tsx examples/get-movements.ts",
|
|
25
28
|
"example:movements:save": "tsx examples/get-movements.ts --save",
|
|
26
29
|
"example:workout:id": "tsx examples/get-workout-by-id.ts",
|
|
@@ -53,7 +56,12 @@
|
|
|
53
56
|
"type": "git",
|
|
54
57
|
"url": "git+https://github.com/dlwiest/ts-tonal-client.git"
|
|
55
58
|
},
|
|
56
|
-
"keywords": [
|
|
59
|
+
"keywords": [
|
|
60
|
+
"tonal",
|
|
61
|
+
"tonal-api",
|
|
62
|
+
"typescript",
|
|
63
|
+
"client"
|
|
64
|
+
],
|
|
57
65
|
"author": "dlwiest",
|
|
58
66
|
"license": "ISC",
|
|
59
67
|
"bugs": {
|
|
@@ -62,10 +70,13 @@
|
|
|
62
70
|
"homepage": "https://github.com/dlwiest/ts-tonal-client#readme",
|
|
63
71
|
"devDependencies": {
|
|
64
72
|
"@rollup/plugin-typescript": "^11.1.0",
|
|
73
|
+
"@types/jest": "^30.0.0",
|
|
65
74
|
"@types/node": "^20.14.8",
|
|
66
75
|
"dotenv": "^16.4.5",
|
|
76
|
+
"jest": "^30.2.0",
|
|
67
77
|
"rollup": "^3.20.2",
|
|
68
78
|
"rollup-plugin-dts": "^5.3.1",
|
|
79
|
+
"ts-jest": "^29.4.5",
|
|
69
80
|
"ts-node": "^10.9.2",
|
|
70
81
|
"tslib": "^2.7.0",
|
|
71
82
|
"tsx": "^4.20.6",
|