soundcloud-api-ts 1.10.2 → 1.11.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/AGENTS.md CHANGED
@@ -125,9 +125,10 @@ try {
125
125
  2. **User token vs client token** — write operations (like, repost, comment, follow, create/update/delete) require a user token obtained via the authorization code flow. A client credentials token won't work.
126
126
  3. **Rate limits exist** — SoundCloud returns 429 when rate limited. The client has built-in retry with exponential backoff (configurable via `maxRetries` and `retryBaseDelay`).
127
127
  4. **Auto token refresh** — pass `onTokenRefresh` in the config to automatically refresh expired tokens on 401.
128
- 5. **No env vars** — the package reads no environment variables. Pass `clientId`, `clientSecret`, and `redirectUri` directly to the constructor.
129
- 6. **IDs can be numbers or strings** all ID parameters accept `string | number`.
130
- 7. **Search pagination** search uses zero-based `pageNumber` (10 results per page), not cursor-based pagination.
128
+ 5. **Request telemetry** — pass `onRequest` in the config to receive `SCRequestTelemetry` after every request (method, path, duration, status, retries, error). Fires on all paths including pagination and retries.
129
+ 6. **No env vars** the package reads no environment variables. Pass `clientId`, `clientSecret`, and `redirectUri` directly to the constructor.
130
+ 7. **IDs can be numbers or strings** all ID parameters accept `string | number`.
131
+ 8. **Search pagination** — search uses zero-based `pageNumber` (10 results per page), not cursor-based pagination.
131
132
 
132
133
  ## Project Structure (for contributors)
133
134
 
package/README.md CHANGED
@@ -423,6 +423,35 @@ const sc = new SoundCloudClient({
423
423
  - **401 errors** trigger `onTokenRefresh` (if configured) instead of retry
424
424
  - Backoff formula: `baseDelay × 2^attempt` with jitter
425
425
 
426
+ ## Request Telemetry
427
+
428
+ Hook into every API request for logging, metrics, or observability:
429
+
430
+ ```ts
431
+ import { SoundCloudClient, type SCRequestTelemetry } from 'soundcloud-api-ts';
432
+
433
+ const sc = new SoundCloudClient({
434
+ clientId: '...',
435
+ clientSecret: '...',
436
+ onRequest: (t: SCRequestTelemetry) => {
437
+ console.log(`[SC] ${t.method} ${t.path} status=${t.status} ${t.durationMs}ms retries=${t.retryCount}`);
438
+ },
439
+ });
440
+ ```
441
+
442
+ The `SCRequestTelemetry` object includes:
443
+
444
+ | Field | Type | Description |
445
+ |---|---|---|
446
+ | `method` | `"GET" \| "POST" \| "PUT" \| "DELETE"` | HTTP method |
447
+ | `path` | `string` | API path or full URL (for pagination) |
448
+ | `durationMs` | `number` | Total wall-clock time including retries |
449
+ | `status` | `number` | Final HTTP status code |
450
+ | `retryCount` | `number` | Number of retries (0 = first attempt succeeded) |
451
+ | `error` | `string?` | Error message if the request failed |
452
+
453
+ Telemetry fires on every code path: direct calls, pagination, retries, and 401 token refresh. It's fully optional — zero overhead when `onRequest` is not set.
454
+
426
455
  ## API Terms Compliance
427
456
 
428
457
  This package is built on SoundCloud's **official documented API** (`api.soundcloud.com`) and follows the [API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use):
@@ -28,8 +28,23 @@ async function parseErrorBody(response) {
28
28
  return void 0;
29
29
  }
30
30
  }
31
- async function scFetch(options, refreshCtx) {
31
+ async function scFetch(options, refreshCtx, onRequest) {
32
32
  const retryConfig = refreshCtx?.retry ?? DEFAULT_RETRY;
33
+ const telemetryCallback = onRequest ?? refreshCtx?.onRequest;
34
+ const startTime = Date.now();
35
+ let retryCount = 0;
36
+ let finalStatus = 0;
37
+ const emitTelemetry = (error) => {
38
+ if (!telemetryCallback) return;
39
+ telemetryCallback({
40
+ method: options.method,
41
+ path: options.path,
42
+ durationMs: Date.now() - startTime,
43
+ status: finalStatus,
44
+ retryCount,
45
+ ...error ? { error } : {}
46
+ });
47
+ };
33
48
  const execute = async (tokenOverride) => {
34
49
  const isAuthPath = options.path.startsWith("/oauth");
35
50
  const url = `${isAuthPath ? AUTH_BASE_URL : BASE_URL}${options.path}`;
@@ -63,22 +78,32 @@ async function scFetch(options, refreshCtx) {
63
78
  body: fetchBody,
64
79
  redirect: "manual"
65
80
  });
81
+ finalStatus = response.status;
66
82
  if (response.status === 302) {
67
83
  const location = response.headers.get("location");
68
- if (location) return location;
84
+ if (location) {
85
+ emitTelemetry();
86
+ return location;
87
+ }
69
88
  }
70
89
  if (response.status === 204 || response.headers.get("content-length") === "0") {
90
+ emitTelemetry();
71
91
  return void 0;
72
92
  }
73
93
  if (response.ok) {
74
- return response.json();
94
+ const result = response.json();
95
+ emitTelemetry();
96
+ return result;
75
97
  }
76
98
  if (!isRetryable(response.status)) {
77
99
  const body2 = await parseErrorBody(response);
78
- throw new SoundCloudError(response.status, response.statusText, body2);
100
+ const err2 = new SoundCloudError(response.status, response.statusText, body2);
101
+ emitTelemetry(err2.message);
102
+ throw err2;
79
103
  }
80
104
  lastResponse = response;
81
105
  if (attempt < retryConfig.maxRetries) {
106
+ retryCount = attempt + 1;
82
107
  const delayMs = getRetryDelay(response, attempt, retryConfig);
83
108
  retryConfig.onDebug?.(
84
109
  `Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
@@ -87,7 +112,9 @@ async function scFetch(options, refreshCtx) {
87
112
  }
88
113
  }
89
114
  const body = await parseErrorBody(lastResponse);
90
- throw new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
115
+ const err = new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
116
+ emitTelemetry(err.message);
117
+ throw err;
91
118
  };
92
119
  try {
93
120
  return await execute();
@@ -100,29 +127,53 @@ async function scFetch(options, refreshCtx) {
100
127
  throw err;
101
128
  }
102
129
  }
103
- async function scFetchUrl(url, token, retryConfig) {
130
+ async function scFetchUrl(url, token, retryConfig, onRequest) {
104
131
  const config = retryConfig ?? DEFAULT_RETRY;
105
132
  const headers = { Accept: "application/json" };
106
133
  if (token) headers["Authorization"] = `OAuth ${token}`;
134
+ const startTime = Date.now();
135
+ let retryCount = 0;
136
+ let finalStatus = 0;
137
+ const emitTelemetry = (error) => {
138
+ if (!onRequest) return;
139
+ onRequest({
140
+ method: "GET",
141
+ path: url,
142
+ durationMs: Date.now() - startTime,
143
+ status: finalStatus,
144
+ retryCount,
145
+ ...error ? { error } : {}
146
+ });
147
+ };
107
148
  let lastResponse;
108
149
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
109
150
  const response = await fetch(url, { method: "GET", headers, redirect: "manual" });
151
+ finalStatus = response.status;
110
152
  if (response.status === 302) {
111
153
  const location = response.headers.get("location");
112
- if (location) return location;
154
+ if (location) {
155
+ emitTelemetry();
156
+ return location;
157
+ }
113
158
  }
114
159
  if (response.status === 204 || response.headers.get("content-length") === "0") {
160
+ emitTelemetry();
115
161
  return void 0;
116
162
  }
117
163
  if (response.ok) {
118
- return response.json();
164
+ const result = response.json();
165
+ emitTelemetry();
166
+ return result;
119
167
  }
120
168
  if (!isRetryable(response.status)) {
121
169
  const body2 = await parseErrorBody(response);
122
- throw new SoundCloudError(response.status, response.statusText, body2);
170
+ const err2 = new SoundCloudError(response.status, response.statusText, body2);
171
+ emitTelemetry(err2.message);
172
+ throw err2;
123
173
  }
124
174
  lastResponse = response;
125
175
  if (attempt < config.maxRetries) {
176
+ retryCount = attempt + 1;
126
177
  const delayMs = getRetryDelay(response, attempt, config);
127
178
  config.onDebug?.(
128
179
  `Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
@@ -131,7 +182,9 @@ async function scFetchUrl(url, token, retryConfig) {
131
182
  }
132
183
  }
133
184
  const body = await parseErrorBody(lastResponse);
134
- throw new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
185
+ const err = new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
186
+ emitTelemetry(err.message);
187
+ throw err;
135
188
  }
136
189
 
137
190
  // src/client/paginate.ts
@@ -210,14 +263,16 @@ var SoundCloudClient = class _SoundCloudClient {
210
263
  return result;
211
264
  },
212
265
  setToken: (a, r) => this.setToken(a, r),
213
- retry: retryConfig
266
+ retry: retryConfig,
267
+ onRequest: config.onRequest
214
268
  } : {
215
269
  getToken,
216
270
  setToken: (
217
271
  /* v8 ignore next */
218
272
  (a, r) => this.setToken(a, r)
219
273
  ),
220
- retry: retryConfig
274
+ retry: retryConfig,
275
+ onRequest: config.onRequest
221
276
  };
222
277
  this.auth = new _SoundCloudClient.Auth(this.config);
223
278
  this.me = new _SoundCloudClient.Me(getToken, refreshCtx);
@@ -267,7 +322,8 @@ var SoundCloudClient = class _SoundCloudClient {
267
322
  */
268
323
  paginate(firstPage) {
269
324
  const token = this._accessToken;
270
- return paginate(firstPage, (url) => scFetchUrl(url, token));
325
+ const onReq = this.config.onRequest;
326
+ return paginate(firstPage, (url) => scFetchUrl(url, token, void 0, onReq));
271
327
  }
272
328
  /**
273
329
  * Async generator that yields individual items across all pages.
@@ -284,7 +340,8 @@ var SoundCloudClient = class _SoundCloudClient {
284
340
  */
285
341
  paginateItems(firstPage) {
286
342
  const token = this._accessToken;
287
- return paginateItems(firstPage, (url) => scFetchUrl(url, token));
343
+ const onReq = this.config.onRequest;
344
+ return paginateItems(firstPage, (url) => scFetchUrl(url, token, void 0, onReq));
288
345
  }
289
346
  /**
290
347
  * Collects all pages into a single flat array.
@@ -302,7 +359,8 @@ var SoundCloudClient = class _SoundCloudClient {
302
359
  */
303
360
  fetchAll(firstPage, options) {
304
361
  const token = this._accessToken;
305
- return fetchAll(firstPage, (url) => scFetchUrl(url, token), options);
362
+ const onReq = this.config.onRequest;
363
+ return fetchAll(firstPage, (url) => scFetchUrl(url, token, void 0, onReq), options);
306
364
  }
307
365
  };
308
366
  ((SoundCloudClient2) => {
@@ -310,6 +368,9 @@ var SoundCloudClient = class _SoundCloudClient {
310
368
  constructor(config) {
311
369
  this.config = config;
312
370
  }
371
+ fetch(opts) {
372
+ return scFetch(opts, void 0, this.config.onRequest);
373
+ }
313
374
  /**
314
375
  * Build the authorization URL to redirect users to SoundCloud's OAuth login page.
315
376
  *
@@ -356,7 +417,7 @@ var SoundCloudClient = class _SoundCloudClient {
356
417
  * @see https://developers.soundcloud.com/docs/api/explorer/open-api#/oauth2/post_oauth2_token
357
418
  */
358
419
  async getClientToken() {
359
- return scFetch({
420
+ return this.fetch({
360
421
  path: "/oauth/token",
361
422
  method: "POST",
362
423
  body: new URLSearchParams({
@@ -391,7 +452,7 @@ var SoundCloudClient = class _SoundCloudClient {
391
452
  code
392
453
  };
393
454
  if (codeVerifier) params.code_verifier = codeVerifier;
394
- return scFetch({
455
+ return this.fetch({
395
456
  path: "/oauth/token",
396
457
  method: "POST",
397
458
  body: new URLSearchParams(params)
@@ -413,7 +474,7 @@ var SoundCloudClient = class _SoundCloudClient {
413
474
  * @see https://developers.soundcloud.com/docs/api/explorer/open-api#/oauth2/post_oauth2_token
414
475
  */
415
476
  async refreshUserToken(refreshToken) {
416
- return scFetch({
477
+ return this.fetch({
417
478
  path: "/oauth/token",
418
479
  method: "POST",
419
480
  body: new URLSearchParams({
@@ -1684,5 +1745,5 @@ var unrepostPlaylist = async (token, playlistId) => {
1684
1745
  var getSoundCloudWidgetUrl = (trackId) => `https%3A//api.soundcloud.com/tracks/${trackId}&show_teaser=false&color=%2300a99d&inverse=false&show_user=false&sharing=false&buying=false&liking=false&show_artwork=false&show_name=false`;
1685
1746
 
1686
1747
  export { SoundCloudClient, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
1687
- //# sourceMappingURL=chunk-ACK4KMGD.mjs.map
1688
- //# sourceMappingURL=chunk-ACK4KMGD.mjs.map
1748
+ //# sourceMappingURL=chunk-DGZEPXIQ.mjs.map
1749
+ //# sourceMappingURL=chunk-DGZEPXIQ.mjs.map