suunto-api-wrapper 1.0.0
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 +163 -0
- package/dist/index.d.mts +511 -0
- package/dist/index.d.ts +511 -0
- package/dist/index.js +414 -0
- package/dist/index.mjs +361 -0
- package/package.json +38 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// src/http/types.ts
|
|
2
|
+
var HttpError = class extends Error {
|
|
3
|
+
constructor(message, init) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "HttpError";
|
|
6
|
+
this.status = init.status;
|
|
7
|
+
this.url = init.url;
|
|
8
|
+
this.method = init.method;
|
|
9
|
+
this.body = init.body;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/http/index.ts
|
|
14
|
+
var DEFAULTS = {
|
|
15
|
+
timeoutMs: 3e4,
|
|
16
|
+
retries: 2,
|
|
17
|
+
retryBackoffMs: 300
|
|
18
|
+
};
|
|
19
|
+
var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
|
|
20
|
+
var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
|
|
21
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
var HttpClient = class {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.baseUrl = options.baseUrl?.replace(/\/+$/, "") ?? "";
|
|
25
|
+
this.defaultHeaders = options.headers ?? {};
|
|
26
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULTS.timeoutMs;
|
|
27
|
+
this.retries = options.retries ?? DEFAULTS.retries;
|
|
28
|
+
this.retryBackoffMs = options.retryBackoffMs ?? DEFAULTS.retryBackoffMs;
|
|
29
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
30
|
+
this.beforeRequest = options.beforeRequest;
|
|
31
|
+
if (typeof this.fetchImpl !== "function") {
|
|
32
|
+
throw new TypeError(
|
|
33
|
+
"No fetch implementation available. Use Node 18+ or pass `fetch` in options."
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
get(path, options) {
|
|
38
|
+
return this.request("GET", path, options);
|
|
39
|
+
}
|
|
40
|
+
delete(path, options) {
|
|
41
|
+
return this.request("DELETE", path, options);
|
|
42
|
+
}
|
|
43
|
+
post(path, options) {
|
|
44
|
+
return this.request("POST", path, options);
|
|
45
|
+
}
|
|
46
|
+
put(path, options) {
|
|
47
|
+
return this.request("PUT", path, options);
|
|
48
|
+
}
|
|
49
|
+
patch(path, options) {
|
|
50
|
+
return this.request("PATCH", path, options);
|
|
51
|
+
}
|
|
52
|
+
async request(method, path, options = {}) {
|
|
53
|
+
const url = this.buildUrl(path, options.query);
|
|
54
|
+
const maxAttempts = (options.retries ?? this.retries) + 1;
|
|
55
|
+
const retryable = IDEMPOTENT_METHODS.has(method.toUpperCase());
|
|
56
|
+
let lastError;
|
|
57
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
58
|
+
try {
|
|
59
|
+
const response = await this.attempt(method, url, options);
|
|
60
|
+
if (!response.ok && retryable && RETRYABLE_STATUS.has(response.status) && attempt < maxAttempts - 1) {
|
|
61
|
+
await sleep(this.backoff(attempt, response.headers));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
return await this.toResult(response, method, url);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
lastError = err;
|
|
67
|
+
if (isAbortError(err) && options.signal?.aborted) throw err;
|
|
68
|
+
if (!retryable || attempt >= maxAttempts - 1) throw err;
|
|
69
|
+
await sleep(this.backoff(attempt));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw lastError;
|
|
73
|
+
}
|
|
74
|
+
async attempt(method, url, options) {
|
|
75
|
+
const headers = {
|
|
76
|
+
...this.defaultHeaders,
|
|
77
|
+
...options.headers
|
|
78
|
+
};
|
|
79
|
+
let body = options.body;
|
|
80
|
+
if (body == null && options.json !== void 0) {
|
|
81
|
+
body = JSON.stringify(options.json);
|
|
82
|
+
headers["content-type"] ?? (headers["content-type"] = "application/json");
|
|
83
|
+
}
|
|
84
|
+
if (!("accept" in headers)) headers["accept"] = "application/json";
|
|
85
|
+
const ctx = { method, url, headers, body };
|
|
86
|
+
if (this.beforeRequest) {
|
|
87
|
+
const next = await this.beforeRequest(ctx);
|
|
88
|
+
if (next) Object.assign(ctx, next);
|
|
89
|
+
}
|
|
90
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
91
|
+
const signal = mergeSignals(options.signal, timeoutMs);
|
|
92
|
+
return this.fetchImpl(ctx.url, {
|
|
93
|
+
method: ctx.method,
|
|
94
|
+
headers: ctx.headers,
|
|
95
|
+
body: ctx.body,
|
|
96
|
+
signal
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async toResult(response, method, url) {
|
|
100
|
+
const data = await parseBody(response);
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new HttpError(
|
|
103
|
+
`${method} ${url} failed with status ${response.status}`,
|
|
104
|
+
{ status: response.status, url, method, body: data }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return { data, status: response.status, headers: response.headers };
|
|
108
|
+
}
|
|
109
|
+
backoff(attempt, headers) {
|
|
110
|
+
const retryAfter = headers?.get("retry-after");
|
|
111
|
+
if (retryAfter) {
|
|
112
|
+
const seconds = Number(retryAfter);
|
|
113
|
+
if (Number.isFinite(seconds)) return seconds * 1e3;
|
|
114
|
+
}
|
|
115
|
+
const jitter = Math.random() * this.retryBackoffMs;
|
|
116
|
+
return this.retryBackoffMs * 2 ** attempt + jitter;
|
|
117
|
+
}
|
|
118
|
+
buildUrl(path, query) {
|
|
119
|
+
const suffixedPath = path.startsWith("/") ? path : `/${path}`;
|
|
120
|
+
const base = /^https?:\/\//i.test(path) ? path : `${this.baseUrl}${suffixedPath}`;
|
|
121
|
+
if (!query) return base;
|
|
122
|
+
const url = new URL(base);
|
|
123
|
+
for (const [key, value] of Object.entries(query)) {
|
|
124
|
+
if (value == null) continue;
|
|
125
|
+
const values = Array.isArray(value) ? value : [value];
|
|
126
|
+
for (const v of values) {
|
|
127
|
+
if (v != null) url.searchParams.append(key, String(v));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return url.toString();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
async function parseBody(response) {
|
|
134
|
+
if (response.status === 204 || response.status === 205) return void 0;
|
|
135
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
136
|
+
const text = await response.text();
|
|
137
|
+
if (!text) return void 0;
|
|
138
|
+
if (contentType.includes("application/json")) {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(text);
|
|
141
|
+
} catch {
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
function mergeSignals(userSignal, timeoutMs) {
|
|
148
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
149
|
+
if (!userSignal) return timeoutSignal;
|
|
150
|
+
if (typeof AbortSignal.any === "function") {
|
|
151
|
+
return AbortSignal.any([userSignal, timeoutSignal]);
|
|
152
|
+
}
|
|
153
|
+
return userSignal;
|
|
154
|
+
}
|
|
155
|
+
function isAbortError(err) {
|
|
156
|
+
return err instanceof Error && err.name === "AbortError";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/otp/index.ts
|
|
160
|
+
import crypto from "crypto";
|
|
161
|
+
var PART1 = "FBkubDYmN28bWVQLLTsWWhI+NAtILCNlPQc5Y";
|
|
162
|
+
var PART2 = "BgiMRYjKA99Jj4HHFIqLmomOFttBQchNzcZU0QrODcDWz4hekc1QGNTPlciNhEKGl5GPDkzFyVX";
|
|
163
|
+
var XOR_KEY = Buffer.from("Bh8nsTyCeC0Ql2drMen78awk84AE3ZxW");
|
|
164
|
+
var RAW = Buffer.from(PART1 + PART2, "base64");
|
|
165
|
+
var SHARED = Buffer.from(
|
|
166
|
+
RAW.map((b, i) => b ^ XOR_KEY[i % XOR_KEY.length])
|
|
167
|
+
);
|
|
168
|
+
var PBKDF2_ITERATIONS = 100;
|
|
169
|
+
var PBKDF2_KEY_LENGTH = 32;
|
|
170
|
+
var TOTP_PERIOD_MS = 3e4;
|
|
171
|
+
var TOTP_DIGITS = 6;
|
|
172
|
+
function generateXtotp(email, now = Date.now()) {
|
|
173
|
+
const key = crypto.pbkdf2Sync(
|
|
174
|
+
SHARED,
|
|
175
|
+
Buffer.from(email, "utf8"),
|
|
176
|
+
PBKDF2_ITERATIONS,
|
|
177
|
+
PBKDF2_KEY_LENGTH,
|
|
178
|
+
"sha1"
|
|
179
|
+
);
|
|
180
|
+
const counter = BigInt(Math.floor(now / TOTP_PERIOD_MS));
|
|
181
|
+
const msg = Buffer.allocUnsafe(8);
|
|
182
|
+
msg.writeBigUInt64BE(counter);
|
|
183
|
+
const mac = crypto.createHmac("sha1", key).update(msg).digest();
|
|
184
|
+
const offset = mac[mac.length - 1] & 15;
|
|
185
|
+
const binary = mac.readUInt32BE(offset) & 2147483647;
|
|
186
|
+
const code = binary % 10 ** TOTP_DIGITS;
|
|
187
|
+
return code.toString().padStart(TOTP_DIGITS, "0");
|
|
188
|
+
}
|
|
189
|
+
function secondsUntilRollover(now = Date.now()) {
|
|
190
|
+
const periodSeconds = TOTP_PERIOD_MS / 1e3;
|
|
191
|
+
return periodSeconds - Math.floor(now / 1e3) % periodSeconds;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/auth/index.ts
|
|
195
|
+
var SPORTS_TRACKER_API = "https://api.sports-tracker.com";
|
|
196
|
+
var DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
|
|
197
|
+
async function login(options) {
|
|
198
|
+
const {
|
|
199
|
+
email,
|
|
200
|
+
password,
|
|
201
|
+
version = "2",
|
|
202
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
203
|
+
baseUrl = SPORTS_TRACKER_API,
|
|
204
|
+
fetch: fetchImpl,
|
|
205
|
+
timeoutMs
|
|
206
|
+
} = options;
|
|
207
|
+
const http = new HttpClient({ baseUrl, fetch: fetchImpl, timeoutMs });
|
|
208
|
+
const body = new URLSearchParams({ l: email, p: password, version });
|
|
209
|
+
const response = await http.post("/apiserver/v1/login2", {
|
|
210
|
+
body,
|
|
211
|
+
headers: {
|
|
212
|
+
"user-agent": userAgent,
|
|
213
|
+
"x-totp": generateXtotp(email),
|
|
214
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return response.data;
|
|
218
|
+
}
|
|
219
|
+
function sessionTokenFrom(response) {
|
|
220
|
+
return response.sessionkey;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/workouts/index.ts
|
|
224
|
+
async function getWorkouts(client, username, params = {}) {
|
|
225
|
+
const { limit = 40, sortonst = true } = params;
|
|
226
|
+
const res = await client.get(
|
|
227
|
+
`/apiserver/v1/workouts/${encodeURIComponent(username)}/public`,
|
|
228
|
+
{ query: { limit, sortonst } }
|
|
229
|
+
);
|
|
230
|
+
return res.data;
|
|
231
|
+
}
|
|
232
|
+
async function getOwnWorkouts(client, params = {}) {
|
|
233
|
+
const { offset = 0, limit = 50, since = 0 } = params;
|
|
234
|
+
const searchParams = new URLSearchParams();
|
|
235
|
+
searchParams.append("offset", String(offset));
|
|
236
|
+
searchParams.append("limit", String(limit));
|
|
237
|
+
searchParams.append("since", String(since));
|
|
238
|
+
const res = await client.get(
|
|
239
|
+
`/apiserver/v1/workouts?${searchParams.toString()}`
|
|
240
|
+
);
|
|
241
|
+
return res.data;
|
|
242
|
+
}
|
|
243
|
+
var WorkoutsResource = class {
|
|
244
|
+
constructor(client) {
|
|
245
|
+
this.client = client;
|
|
246
|
+
}
|
|
247
|
+
/** The authenticated user's own workouts. */
|
|
248
|
+
own(params) {
|
|
249
|
+
return getOwnWorkouts(this.client, params);
|
|
250
|
+
}
|
|
251
|
+
/** A given user's public workouts. */
|
|
252
|
+
public(username, params) {
|
|
253
|
+
return getWorkouts(this.client, username, params);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/users/index.ts
|
|
258
|
+
async function getUserByName(client, username) {
|
|
259
|
+
const res = await client.get(
|
|
260
|
+
`/apiserver/v1/user/name/${encodeURIComponent(username)}`
|
|
261
|
+
);
|
|
262
|
+
return res.data;
|
|
263
|
+
}
|
|
264
|
+
async function searchUsers(client, searchTerms) {
|
|
265
|
+
const res = await client.get(
|
|
266
|
+
`/apiserver/v1/user/search/${encodeURIComponent(searchTerms)}`
|
|
267
|
+
);
|
|
268
|
+
return res.data;
|
|
269
|
+
}
|
|
270
|
+
var UsersResource = class {
|
|
271
|
+
constructor(client) {
|
|
272
|
+
this.client = client;
|
|
273
|
+
}
|
|
274
|
+
/** A user's public profile, by username. */
|
|
275
|
+
byName(username) {
|
|
276
|
+
return getUserByName(this.client, username);
|
|
277
|
+
}
|
|
278
|
+
/** Search for users by name/username. */
|
|
279
|
+
search(searchTerms) {
|
|
280
|
+
return searchUsers(this.client, searchTerms);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// src/gear/index.ts
|
|
285
|
+
async function getLatestGear(client, username, params = {}) {
|
|
286
|
+
const { allTypes = true } = params;
|
|
287
|
+
const res = await client.get(
|
|
288
|
+
`/apiserver/v1/gear/${encodeURIComponent(username)}/latest`,
|
|
289
|
+
{ query: { allTypes } }
|
|
290
|
+
);
|
|
291
|
+
return res.data;
|
|
292
|
+
}
|
|
293
|
+
var GearResource = class {
|
|
294
|
+
constructor(client) {
|
|
295
|
+
this.client = client;
|
|
296
|
+
}
|
|
297
|
+
/** A user's latest (?) gear. */
|
|
298
|
+
latest(username, params) {
|
|
299
|
+
return getLatestGear(this.client, username, params);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// src/client.ts
|
|
304
|
+
var SuuntoClient = class _SuuntoClient {
|
|
305
|
+
constructor(options) {
|
|
306
|
+
const {
|
|
307
|
+
email,
|
|
308
|
+
sessionKey,
|
|
309
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
310
|
+
baseUrl,
|
|
311
|
+
headers,
|
|
312
|
+
...rest
|
|
313
|
+
} = options;
|
|
314
|
+
this.sessionKey = sessionKey;
|
|
315
|
+
this.http = new HttpClient({
|
|
316
|
+
baseUrl: baseUrl ?? SPORTS_TRACKER_API,
|
|
317
|
+
headers: { "user-agent": userAgent, ...headers },
|
|
318
|
+
...rest,
|
|
319
|
+
beforeRequest: (ctx) => {
|
|
320
|
+
if (email) ctx.headers["x-totp"] = generateXtotp(email);
|
|
321
|
+
if (sessionKey) ctx.headers["sttauthorization"] = sessionKey;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
this.workouts = new WorkoutsResource(this.http);
|
|
325
|
+
this.users = new UsersResource(this.http);
|
|
326
|
+
this.gear = new GearResource(this.http);
|
|
327
|
+
}
|
|
328
|
+
/** Log in with email/password and return an authenticated client. */
|
|
329
|
+
static async login(options) {
|
|
330
|
+
const session = await login(options);
|
|
331
|
+
const sessionKey = sessionTokenFrom(session);
|
|
332
|
+
const { password: _password, version: _version, ...clientOptions } = options;
|
|
333
|
+
return new _SuuntoClient({ ...clientOptions, sessionKey });
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Create a client with no credentials, for unauthenticated endpoints only
|
|
337
|
+
* (e.g. {@link UsersResource.byName}). No `x-totp` or session header is sent.
|
|
338
|
+
*/
|
|
339
|
+
static unauthenticated(options = {}) {
|
|
340
|
+
return new _SuuntoClient(options);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
export {
|
|
344
|
+
DEFAULT_USER_AGENT,
|
|
345
|
+
GearResource,
|
|
346
|
+
HttpClient,
|
|
347
|
+
HttpError,
|
|
348
|
+
SPORTS_TRACKER_API,
|
|
349
|
+
SuuntoClient,
|
|
350
|
+
UsersResource,
|
|
351
|
+
WorkoutsResource,
|
|
352
|
+
generateXtotp,
|
|
353
|
+
getLatestGear,
|
|
354
|
+
getOwnWorkouts,
|
|
355
|
+
getUserByName,
|
|
356
|
+
getWorkouts,
|
|
357
|
+
login,
|
|
358
|
+
searchUsers,
|
|
359
|
+
secondsUntilRollover,
|
|
360
|
+
sessionTokenFrom
|
|
361
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "suunto-api-wrapper",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Unofficial typed TypeScript client for the Suunto app API (Sports Tracker backend). Not affiliated with or endorsed by Suunto or Sports Tracker.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/Marius-Ar/suunto-api-wrapper"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"module": "dist/index.mjs",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.mjs",
|
|
15
|
+
"require": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"dev": "tsup --watch",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "ISC",
|
|
31
|
+
"type": "commonjs",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.9.1",
|
|
34
|
+
"tsup": "^8.5.1",
|
|
35
|
+
"typescript": "^6.0.3",
|
|
36
|
+
"vitest": "^4.1.7"
|
|
37
|
+
}
|
|
38
|
+
}
|