soundcloud-api-ts 1.11.3 → 1.12.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
@@ -123,9 +123,14 @@ try {
123
123
 
124
124
  1. **Token required for ALL requests** — even public endpoints like `getTrack` need at least a client credentials token. Call `setToken()` first.
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
- 3. **Rate limits exist** — SoundCloud returns 429 when rate limited. The client has built-in retry with exponential backoff (configurable via `maxRetries` and `retryBaseDelay`).
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`). `Retry-After` header is honored (capped 60s).
127
127
  4. **Auto token refresh** — pass `onTokenRefresh` in the config to automatically refresh expired tokens on 401.
128
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. **sc.raw** — `sc.raw.get('/tracks/{id}', { id: 123456 })` calls any endpoint without a typed wrapper. Returns `RawResponse<T>` with `{ data, status, headers }`. Does NOT throw on non-2xx — check `res.status` yourself. Good for endpoints not yet wrapped.
130
+ 7. **Fetch injection** — pass `fetch` and `AbortController` in the constructor for Bun/Deno/Cloudflare Workers portability. No Node-only APIs used at runtime.
131
+ 8. **Deduplication** — concurrent identical GETs share a single in-flight promise (`dedupe: true` by default). Prevents redundant fetches in SSR or concurrent component trees.
132
+ 9. **Cache** — pass a `SoundCloudCache` implementation in the constructor to cache GET responses. Base package defines the interface only; bring your own backend. `cacheTtlMs` defaults to 60000ms.
133
+ 10. **Retry hook** — pass `onRetry` to receive `RetryInfo` on each retry: `{ attempt, delayMs, reason, status?, url }`.
129
134
  6. **No env vars** — the package reads no environment variables. Pass `clientId`, `clientSecret`, and `redirectUri` directly to the constructor.
130
135
  7. **IDs can be numbers or strings** — all ID parameters accept `string | number`.
131
136
  8. **Search pagination** — search uses zero-based `pageNumber` (10 results per page), not cursor-based pagination.
package/README.md CHANGED
@@ -267,6 +267,11 @@ sc.search.playlists(query, pageNumber?, options?)
267
267
  sc.resolve.resolveUrl(url, options?)
268
268
  ```
269
269
 
270
+ // Raw escape hatch — call any endpoint
271
+ sc.raw.get('/tracks/{id}', { id: 123456 })
272
+ sc.raw.post('/tracks/{id}/comments', { body: { body: 'great track' } })
273
+ sc.raw.request({ method: 'GET', path: '/me', query: {} })
274
+
270
275
  Where `options` is `{ token?: string }` — only needed to override the stored token.
271
276
 
272
277
  ## Standalone Functions
@@ -403,21 +408,105 @@ try {
403
408
 
404
409
  Error messages are parsed directly from SoundCloud's API response format, giving you the most useful message available.
405
410
 
411
+ ## Raw API — Call Any Endpoint
412
+
413
+ `sc.raw` is a low-level escape hatch that lets you call any SoundCloud API endpoint — including ones not yet wrapped — while still using your configured auth and fetch.
414
+
415
+ ```ts
416
+ import { SoundCloudClient, type RawResponse } from 'soundcloud-api-ts';
417
+
418
+ const sc = new SoundCloudClient({ clientId, clientSecret });
419
+ const token = await sc.auth.getClientToken();
420
+ sc.setToken(token.access_token);
421
+
422
+ // Path templating: {id} is replaced from the params object
423
+ const res: RawResponse = await sc.raw.get('/tracks/{id}', { id: 123456 });
424
+ console.log(res.data); // parsed JSON body
425
+ console.log(res.status); // 200
426
+ console.log(res.headers); // response headers
427
+
428
+ // POST with body
429
+ await sc.raw.post('/tracks/{id}/comments', { id: 123456, body: { body: 'great track', timestamp: 30000 } });
430
+
431
+ // Fully generic
432
+ await sc.raw.request({ method: 'DELETE', path: '/tracks/{id}', query: { id: 123456 } });
433
+ ```
434
+
435
+ `sc.raw` returns `RawResponse<T = unknown>` — `{ data: T, status: number, headers: Record<string, string> }`. It does **not** throw on non-2xx status codes; check `res.status` yourself.
436
+
437
+ ---
438
+
439
+ ## In-Flight Deduplication & Caching
440
+
441
+ ### GET Coalescing (default on)
442
+
443
+ When multiple callers fire the same GET request simultaneously (SSR, React StrictMode, concurrent components), only one fetch is made. All callers share the same promise.
444
+
445
+ ```ts
446
+ const sc = new SoundCloudClient({
447
+ clientId, clientSecret,
448
+ dedupe: true, // default — set false to disable
449
+ });
450
+ ```
451
+
452
+ ### Pluggable Cache
453
+
454
+ Bring your own cache backend — in-memory, Redis, Cloudflare KV, whatever. The base package defines the interface only (no implementation, no deps):
455
+
456
+ ```ts
457
+ import { SoundCloudClient, type SoundCloudCache } from 'soundcloud-api-ts';
458
+
459
+ const myCache: SoundCloudCache = {
460
+ get: (key) => store.get(key),
461
+ set: (key, value, { ttlMs }) => store.set(key, value, ttlMs),
462
+ delete: (key) => store.delete(key),
463
+ };
464
+
465
+ const sc = new SoundCloudClient({
466
+ clientId, clientSecret,
467
+ cache: myCache,
468
+ cacheTtlMs: 30_000, // 30s default per GET response
469
+ });
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Runtime Portability
475
+
476
+ Pass a custom `fetch` implementation to work in any runtime — Cloudflare Workers, Deno, Bun, or environments without a global `fetch`:
477
+
478
+ ```ts
479
+ const sc = new SoundCloudClient({
480
+ clientId, clientSecret,
481
+ fetch: myCustomFetch, // optional: custom fetch
482
+ AbortController: myAbortCtrl, // optional: custom AbortController
483
+ });
484
+ ```
485
+
486
+ No Node-only APIs are used at runtime. The client works anywhere `fetch` is available.
487
+
488
+ ---
489
+
406
490
  ## Rate Limiting & Retries
407
491
 
408
492
  The client automatically retries on **429 Too Many Requests** and **5xx Server Errors** with exponential backoff:
409
493
 
410
494
  ```ts
495
+ import { SoundCloudClient, type RetryInfo } from 'soundcloud-api-ts';
496
+
411
497
  const sc = new SoundCloudClient({
412
498
  clientId: "...",
413
499
  clientSecret: "...",
414
500
  maxRetries: 3, // default: 3
415
501
  retryBaseDelay: 1000, // default: 1000ms
416
502
  onDebug: (msg) => console.log(msg), // optional retry logging
503
+ onRetry: (info: RetryInfo) => {
504
+ console.warn(`[SC] retry #${info.attempt} — ${info.reason} (${info.status}) delay=${info.delayMs}ms url=${info.url}`);
505
+ },
417
506
  });
418
507
  ```
419
508
 
420
- - **429 responses** respect the `Retry-After` header when present
509
+ - **429 responses** use the `Retry-After` header value as the delay (capped at 60s)
421
510
  - **5xx responses** (500, 502, 503, 504) are retried with exponential backoff
422
511
  - **4xx errors** (except 429) are NOT retried — they throw immediately
423
512
  - **401 errors** trigger `onTokenRefresh` (if configured) instead of retry
@@ -17,7 +17,7 @@ function getRetryDelay(response, attempt, config) {
17
17
  const retryAfter = response.headers.get("retry-after");
18
18
  if (retryAfter) {
19
19
  const seconds = Number(retryAfter);
20
- if (!Number.isNaN(seconds)) return seconds * 1e3;
20
+ if (!Number.isNaN(seconds)) return Math.min(seconds * 1e3, 6e4);
21
21
  }
22
22
  }
23
23
  const base = config.retryBaseDelay * Math.pow(2, attempt);
@@ -93,9 +93,26 @@ async function scFetch(options, refreshCtx, onRequest) {
93
93
  return void 0;
94
94
  }
95
95
  if (response.ok) {
96
- const result = response.json();
96
+ const data = await response.json();
97
+ if (typeof data === "object" && data !== null) {
98
+ const metaHeaders = {};
99
+ if (typeof response.headers.forEach === "function") {
100
+ response.headers.forEach((value, key) => {
101
+ metaHeaders[key] = value;
102
+ });
103
+ }
104
+ try {
105
+ Object.defineProperty(data, "_meta", {
106
+ value: { status: response.status, headers: metaHeaders },
107
+ enumerable: false,
108
+ configurable: true,
109
+ writable: true
110
+ });
111
+ } catch {
112
+ }
113
+ }
97
114
  emitTelemetry();
98
- return result;
115
+ return data;
99
116
  }
100
117
  if (!isRetryable(response.status)) {
101
118
  const body2 = await parseErrorBody(response);
@@ -110,6 +127,13 @@ async function scFetch(options, refreshCtx, onRequest) {
110
127
  retryConfig.onDebug?.(
111
128
  `Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
112
129
  );
130
+ retryConfig.onRetry?.({
131
+ attempt: retryCount,
132
+ delayMs,
133
+ reason: `${response.status} ${response.statusText}`,
134
+ status: response.status,
135
+ url
136
+ });
113
137
  await delay(delayMs);
114
138
  }
115
139
  }
@@ -163,9 +187,26 @@ async function scFetchUrl(url, token, retryConfig, onRequest) {
163
187
  return void 0;
164
188
  }
165
189
  if (response.ok) {
166
- const result = response.json();
190
+ const data = await response.json();
191
+ if (typeof data === "object" && data !== null) {
192
+ const metaHeaders = {};
193
+ if (typeof response.headers.forEach === "function") {
194
+ response.headers.forEach((value, key) => {
195
+ metaHeaders[key] = value;
196
+ });
197
+ }
198
+ try {
199
+ Object.defineProperty(data, "_meta", {
200
+ value: { status: response.status, headers: metaHeaders },
201
+ enumerable: false,
202
+ configurable: true,
203
+ writable: true
204
+ });
205
+ } catch {
206
+ }
207
+ }
167
208
  emitTelemetry();
168
- return result;
209
+ return data;
169
210
  }
170
211
  if (!isRetryable(response.status)) {
171
212
  const body2 = await parseErrorBody(response);
@@ -180,6 +221,13 @@ async function scFetchUrl(url, token, retryConfig, onRequest) {
180
221
  config.onDebug?.(
181
222
  `Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
182
223
  );
224
+ config.onRetry?.({
225
+ attempt: retryCount,
226
+ delayMs,
227
+ reason: `${response.status} ${response.statusText}`,
228
+ status: response.status,
229
+ url
230
+ });
183
231
  await delay(delayMs);
184
232
  }
185
233
  }
@@ -217,6 +265,96 @@ async function fetchAll(firstPage, fetchNext, options) {
217
265
  return result;
218
266
  }
219
267
 
268
+ // src/client/raw.ts
269
+ var RawClient = class {
270
+ constructor(baseUrl, getToken, fetchFn) {
271
+ this.baseUrl = baseUrl;
272
+ this.getToken = getToken;
273
+ this.fetchFn = fetchFn;
274
+ }
275
+ /**
276
+ * Make a raw HTTP request. Path template placeholders like `{id}` are substituted
277
+ * from matching keys in `query` before the remaining query params are appended to
278
+ * the URL as search parameters.
279
+ */
280
+ async request({
281
+ method,
282
+ path,
283
+ query,
284
+ body,
285
+ token
286
+ }) {
287
+ let resolvedPath = path;
288
+ const remainingQuery = {};
289
+ if (query) {
290
+ for (const [key, value] of Object.entries(query)) {
291
+ if (value === void 0) continue;
292
+ const placeholder = `{${key}}`;
293
+ if (resolvedPath.includes(placeholder)) {
294
+ resolvedPath = resolvedPath.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
295
+ } else {
296
+ remainingQuery[key] = String(value);
297
+ }
298
+ }
299
+ }
300
+ const fullUrl = resolvedPath.startsWith("http") ? new URL(resolvedPath) : new URL(resolvedPath, this.baseUrl);
301
+ for (const [key, value] of Object.entries(remainingQuery)) {
302
+ fullUrl.searchParams.set(key, value);
303
+ }
304
+ const headers = {
305
+ Accept: "application/json"
306
+ };
307
+ const authToken = token ?? this.getToken();
308
+ if (authToken) {
309
+ headers["Authorization"] = `OAuth ${authToken}`;
310
+ }
311
+ let fetchBody;
312
+ if (body !== void 0) {
313
+ headers["Content-Type"] = "application/json";
314
+ fetchBody = JSON.stringify(body);
315
+ }
316
+ const response = await this.fetchFn(fullUrl.toString(), {
317
+ method,
318
+ headers,
319
+ body: fetchBody
320
+ });
321
+ const responseHeaders = {};
322
+ if (typeof response.headers.forEach === "function") {
323
+ response.headers.forEach((value, key) => {
324
+ responseHeaders[key] = value;
325
+ });
326
+ }
327
+ let data;
328
+ const contentLength = response.headers.get("content-length");
329
+ if (response.status === 204 || contentLength === "0") {
330
+ data = void 0;
331
+ } else {
332
+ try {
333
+ data = await response.json();
334
+ } catch {
335
+ data = void 0;
336
+ }
337
+ }
338
+ return { data, status: response.status, headers: responseHeaders };
339
+ }
340
+ /** GET shorthand */
341
+ get(path, params) {
342
+ return this.request({ method: "GET", path, query: params });
343
+ }
344
+ /** POST shorthand */
345
+ post(path, body) {
346
+ return this.request({ method: "POST", path, body });
347
+ }
348
+ /** PUT shorthand */
349
+ put(path, body) {
350
+ return this.request({ method: "PUT", path, body });
351
+ }
352
+ /** DELETE shorthand */
353
+ delete(path) {
354
+ return this.request({ method: "DELETE", path });
355
+ }
356
+ };
357
+
220
358
  // src/client/SoundCloudClient.ts
221
359
  function resolveToken(tokenGetter, explicit) {
222
360
  const t = explicit ?? tokenGetter();
@@ -245,6 +383,8 @@ exports.SoundCloudClient = class _SoundCloudClient {
245
383
  likes;
246
384
  /** Repost/unrepost actions (/reposts) */
247
385
  reposts;
386
+ /** Low-level raw HTTP client — returns status/headers without throwing on non-2xx */
387
+ raw;
248
388
  /**
249
389
  * Creates a new SoundCloudClient instance.
250
390
  *
@@ -256,7 +396,8 @@ exports.SoundCloudClient = class _SoundCloudClient {
256
396
  const retryConfig = {
257
397
  maxRetries: config.maxRetries ?? 3,
258
398
  retryBaseDelay: config.retryBaseDelay ?? 1e3,
259
- onDebug: config.onDebug
399
+ onDebug: config.onDebug,
400
+ onRetry: config.onRetry
260
401
  };
261
402
  const refreshCtx = config.onTokenRefresh ? {
262
403
  getToken,
@@ -285,6 +426,7 @@ exports.SoundCloudClient = class _SoundCloudClient {
285
426
  this.resolve = new _SoundCloudClient.Resolve(getToken, refreshCtx);
286
427
  this.likes = new _SoundCloudClient.Likes(getToken, refreshCtx);
287
428
  this.reposts = new _SoundCloudClient.Reposts(getToken, refreshCtx);
429
+ this.raw = new RawClient("https://api.soundcloud.com", getToken, config.fetch ?? globalThis.fetch);
288
430
  }
289
431
  /**
290
432
  * Store an access token (and optionally refresh token) on this client instance.
@@ -1458,6 +1600,89 @@ exports.SoundCloudClient = class _SoundCloudClient {
1458
1600
  SoundCloudClient2.Reposts = Reposts;
1459
1601
  })(exports.SoundCloudClient || (exports.SoundCloudClient = {}));
1460
1602
 
1603
+ // src/client/dedupe.ts
1604
+ var InFlightDeduper = class {
1605
+ inFlight = /* @__PURE__ */ new Map();
1606
+ /**
1607
+ * Return an existing in-flight promise for `key`, or start a new one via `factory`.
1608
+ * The entry is removed from the map once the promise settles (resolve or reject).
1609
+ */
1610
+ add(key, factory) {
1611
+ const existing = this.inFlight.get(key);
1612
+ if (existing) return existing;
1613
+ const promise = factory().finally(() => {
1614
+ this.inFlight.delete(key);
1615
+ });
1616
+ this.inFlight.set(key, promise);
1617
+ return promise;
1618
+ }
1619
+ /** Number of currently in-flight requests */
1620
+ get size() {
1621
+ return this.inFlight.size;
1622
+ }
1623
+ };
1624
+
1625
+ // src/client/registry.ts
1626
+ var IMPLEMENTED_OPERATIONS = [
1627
+ // Auth
1628
+ "post_oauth2_token",
1629
+ "delete_oauth2_token",
1630
+ // Me
1631
+ "get_me",
1632
+ "get_me_activities",
1633
+ "get_me_activities_own",
1634
+ "get_me_activities_tracks",
1635
+ "get_me_likes_tracks",
1636
+ "get_me_likes_playlists",
1637
+ "get_me_followings",
1638
+ "get_me_followings_tracks",
1639
+ "post_me_followings_user_id",
1640
+ "delete_me_followings_user_id",
1641
+ "get_me_followers",
1642
+ "get_me_playlists",
1643
+ "get_me_tracks",
1644
+ // Users
1645
+ "get_users_user_id",
1646
+ "get_users_user_id_followers",
1647
+ "get_users_user_id_followings",
1648
+ "get_users_user_id_tracks",
1649
+ "get_users_user_id_playlists",
1650
+ "get_users_user_id_likes_tracks",
1651
+ "get_users_user_id_likes_playlists",
1652
+ "get_users_user_id_web_profiles",
1653
+ // Tracks
1654
+ "get_tracks_track_id",
1655
+ "put_tracks_track_id",
1656
+ "delete_tracks_track_id",
1657
+ "get_tracks_track_id_comments",
1658
+ "post_tracks_track_id_comments",
1659
+ "get_tracks_track_id_likes",
1660
+ "get_tracks_track_id_reposts",
1661
+ "get_tracks_track_id_related",
1662
+ "get_tracks_track_id_streams",
1663
+ "post_likes_tracks_track_id",
1664
+ "delete_likes_tracks_track_id",
1665
+ "post_reposts_tracks_track_id",
1666
+ "delete_reposts_tracks_track_id",
1667
+ // Playlists
1668
+ "get_playlists_playlist_id",
1669
+ "post_playlists",
1670
+ "put_playlists_playlist_id",
1671
+ "delete_playlists_playlist_id",
1672
+ "get_playlists_playlist_id_tracks",
1673
+ "get_playlists_playlist_id_reposts",
1674
+ "post_likes_playlists_playlist_id",
1675
+ "delete_likes_playlists_playlist_id",
1676
+ "post_reposts_playlists_playlist_id",
1677
+ "delete_reposts_playlists_playlist_id",
1678
+ // Search
1679
+ "get_tracks",
1680
+ "get_users",
1681
+ "get_playlists",
1682
+ // Resolve
1683
+ "get_resolve"
1684
+ ];
1685
+
1461
1686
  // src/auth/getClientToken.ts
1462
1687
  var getClientToken = (clientId, clientSecret) => {
1463
1688
  const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
@@ -1750,6 +1975,9 @@ var unrepostPlaylist = async (token, playlistId) => {
1750
1975
  // src/utils/widget.ts
1751
1976
  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`;
1752
1977
 
1978
+ exports.IMPLEMENTED_OPERATIONS = IMPLEMENTED_OPERATIONS;
1979
+ exports.InFlightDeduper = InFlightDeduper;
1980
+ exports.RawClient = RawClient;
1753
1981
  exports.createPlaylist = createPlaylist;
1754
1982
  exports.createTrackComment = createTrackComment;
1755
1983
  exports.deletePlaylist = deletePlaylist;
@@ -1811,5 +2039,5 @@ exports.unrepostPlaylist = unrepostPlaylist;
1811
2039
  exports.unrepostTrack = unrepostTrack;
1812
2040
  exports.updatePlaylist = updatePlaylist;
1813
2041
  exports.updateTrack = updateTrack;
1814
- //# sourceMappingURL=chunk-2747SK6H.js.map
1815
- //# sourceMappingURL=chunk-2747SK6H.js.map
2042
+ //# sourceMappingURL=chunk-D7AF372V.js.map
2043
+ //# sourceMappingURL=chunk-D7AF372V.js.map