soundcloud-api-ts 1.10.2 → 1.11.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/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) => {
@@ -1684,5 +1742,5 @@ var unrepostPlaylist = async (token, playlistId) => {
1684
1742
  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
1743
 
1686
1744
  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
1745
+ //# sourceMappingURL=chunk-5FCXAR2S.mjs.map
1746
+ //# sourceMappingURL=chunk-5FCXAR2S.mjs.map