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