@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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # TypeScript Tonal Client
2
2
 
3
+ [![npm version](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client.svg)](https://badge.fury.io/js/@dlwiest%2Fts-tonal-client)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@dlwiest/ts-tonal-client.svg)](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
- Clone the repository and install dependencies:
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 './src/index'
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 (!this.isTokenValid()) {
54
- throw new TonalClientError('Token expired. Call authenticate() first.');
55
+ async getValidToken() {
56
+ if (this.isTokenValid()) {
57
+ return this.idToken;
55
58
  }
56
- return this.idToken;
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 (!this.isTokenValid()) {
58
- throw new TonalClientError('Token expired. Call authenticate() first.');
59
+ async getValidToken() {
60
+ if (this.isTokenValid()) {
61
+ return this.idToken;
59
62
  }
60
- return this.idToken;
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.0",
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
- "prepublishOnly": "npm run typecheck && npm run build",
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": ["tonal", "tonal-api", "typescript", "client"],
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",