sonovault 1.0.0 → 1.2.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 CHANGED
@@ -3,11 +3,11 @@
3
3
  [![CI](https://github.com/rekordcloud/sonovault-js/actions/workflows/ci.yml/badge.svg)](https://github.com/rekordcloud/sonovault-js/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/sonovault)](https://www.npmjs.com/package/sonovault)
5
5
 
6
- TypeScript/Node client for the **[SonoVault](https://sonovault.now)** music metadata API 90M+ tracks with ISRC, ISWC, genre, record label, canonical release dates, and cross-platform IDs for Spotify, Apple Music, Tidal, Beatport, Discogs, and MusicBrainz, resolved in a single call.
6
+ TypeScript/Node client for the **[SonoVault](https://sonovault.now)** music metadata API. 90M+ tracks with ISRC, ISWC, genre, record label, canonical release dates, and cross-platform IDs for Spotify, Apple Music, Tidal, Beatport, Discogs, and MusicBrainz. One call resolves them all.
7
7
 
8
- - **One key, no OAuth** a single `x-api-key` header, no approval queue.
9
- - **Free tier** 1,000 requests/month, no credit card: [get an API key](https://sonovault.now).
10
- - **Docs** full API reference at [sonovault.now/docs](https://sonovault.now/docs).
8
+ - **One key, no OAuth.** A single `x-api-key` header, no approval queue.
9
+ - **Free tier.** 1,000 requests/month, no credit card: [get an API key](https://sonovault.now).
10
+ - **Docs.** Full API reference at [sonovault.now/docs](https://sonovault.now/docs).
11
11
 
12
12
  ## Install
13
13
 
@@ -17,6 +17,8 @@ npm install sonovault
17
17
 
18
18
  Node 18+ (uses the built-in `fetch`). ESM and CommonJS both supported.
19
19
 
20
+ Use this library server-side. Shipping your API key in browser code exposes it to anyone who opens devtools. If a key leaks, rotate it in your [dashboard](https://sonovault.now/dashboard/keys).
21
+
20
22
  ## Quickstart
21
23
 
22
24
  ```ts
@@ -32,16 +34,16 @@ console.log(results[0].genre, results[0].releases[0]?.label?.name);
32
34
  // Resolve that ISRC to its ID on every platform
33
35
  const { links } = await sv.tracks.links({ isrc: "GBDUW0000053" });
34
36
  for (const link of links) {
35
- console.log(link.source, link.url); // spotify https://open.spotify.com/track/…
37
+ console.log(link.source, link.url); // spotify https://open.spotify.com/track/...
36
38
  }
37
39
 
38
- // Recording composition (ISWC), for royalty/publishing workflows
40
+ // Recording to composition (ISWC), for royalty and publishing workflows
39
41
  const work = await sv.tracks.iswc({ isrc: "GBDUW0000053" });
40
42
  ```
41
43
 
42
44
  ## Bulk resolve
43
45
 
44
- Resolve up to 100 lines track names, ISRCs, or platform IDs in one request (great for enriching play logs and library exports):
46
+ Resolve up to 100 lines in one request: track names, ISRCs, or platform IDs. Useful for enriching play logs and library exports.
45
47
 
46
48
  ```ts
47
49
  const batch = await sv.tracks.resolve({
@@ -59,13 +61,23 @@ for (const row of batch.results) {
59
61
 
60
62
  ## Pagination
61
63
 
62
- List endpoints return `{ results, next_cursor }` pass the cursor back to get the next page (`next_cursor` is `null` on the last page):
64
+ List endpoints return `{ results, next_cursor }`. Pass the cursor back to get the next page. `next_cursor` is `null` on the last page.
65
+
66
+ ```ts
67
+ import { paginate } from "sonovault";
68
+
69
+ for await (const release of paginate((cursor) => sv.artists.releases(42, { cursor }))) {
70
+ console.log(release.title);
71
+ }
72
+ ```
73
+
74
+ Or walk the cursor yourself:
63
75
 
64
76
  ```ts
65
77
  let cursor: string | undefined;
66
78
  do {
67
79
  const page = await sv.artists.releases(42, { cursor });
68
- // use page.results
80
+ // ...use page.results
69
81
  cursor = page.next_cursor ?? undefined;
70
82
  } while (cursor);
71
83
  ```
@@ -86,7 +98,12 @@ try {
86
98
  }
87
99
  ```
88
100
 
89
- Rate-limited responses that carry a `Retry-After` header are retried automatically (twice by default; configure with `maxRetries`).
101
+ Rate-limited responses that carry a `Retry-After` header are retried automatically. The default is 2 retries, configurable with `maxRetries`.
102
+
103
+
104
+ ## Examples
105
+
106
+ Runnable scripts live in [`examples/`](examples/): find an ISRC, resolve cross-platform links, enrich a play log, follow live stream events over SSE, and verify webhook deliveries.
90
107
 
91
108
  ## API coverage
92
109
 
@@ -99,15 +116,15 @@ Rate-limited responses that carry a `Retry-After` header are retried automatical
99
116
  | `sv.genres` | `list` |
100
117
  | `sv.suggestions` | `submit`, `list` |
101
118
  | `sv.streams` | `create`, `list`, `get`, `update`, `history`, `report`, `live`, `stop` |
102
- | `sv.webhooks` | `create`, `list`, `update`, `delete`, `test`, `deliveries` |
119
+ | `sv.webhooks` | `create`, `list`, `get`, `update`, `delete`, `test`, `deliveries` |
103
120
 
104
- Some endpoints (audio identify, browse, charts, stream monitoring) need a paid tier see [pricing](https://sonovault.now/pricing). Everything else works on the free tier.
121
+ Some endpoints (audio identify, browse, stream monitoring) need a paid tier. See [pricing](https://sonovault.now/pricing). Everything else works on the free tier.
105
122
 
106
123
  ## Related
107
124
 
108
- - [SonoVault API docs](https://sonovault.now/docs) full endpoint reference with examples in 8 languages
109
- - [sonovault-python](https://github.com/rekordcloud/sonovault-python) the Python client
110
- - [Free ISRC lookup](https://sonovault.now/isrc-lookup) · [ISWC lookup](https://sonovault.now/iswc-lookup) browser tools built on the same API
125
+ - [SonoVault API docs](https://sonovault.now/docs). Full endpoint reference with examples in 8 languages.
126
+ - [sonovault-python](https://github.com/rekordcloud/sonovault-python). The Python client.
127
+ - [Free ISRC lookup](https://sonovault.now/isrc-lookup) and [ISWC lookup](https://sonovault.now/iswc-lookup). Browser tools built on the same API.
111
128
 
112
129
  ## License
113
130
 
package/dist/index.cjs CHANGED
@@ -21,7 +21,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  SonoVault: () => SonoVault,
24
- SonoVaultError: () => SonoVaultError
24
+ SonoVaultError: () => SonoVaultError,
25
+ paginate: () => paginate,
26
+ verifyWebhookSignature: () => verifyWebhookSignature
25
27
  });
26
28
  module.exports = __toCommonJS(index_exports);
27
29
 
@@ -47,6 +49,9 @@ var SonoVaultError = class extends Error {
47
49
  }
48
50
  };
49
51
 
52
+ // src/version.ts
53
+ var VERSION = "1.2.0";
54
+
50
55
  // src/client.ts
51
56
  var SonoVault = class {
52
57
  constructor(options) {
@@ -120,8 +125,17 @@ var SonoVault = class {
120
125
  update: (id, body) => this.request(`/v1/streams/${id}`, { method: "PATCH", json: body }),
121
126
  history: (id, params = {}) => this.request(`/v1/streams/${id}/history`, { query: params }),
122
127
  report: (params) => this.request("/v1/streams/report", { query: params }),
123
- /** What's playing right now across your monitored streams. */
124
- live: () => this.request("/v1/streams/live"),
128
+ /**
129
+ * Real-time play events for your monitored streams, as an async iterator
130
+ * over Server-Sent Events. Runs until you `break` or abort the signal.
131
+ *
132
+ * ```ts
133
+ * for await (const event of sv.streams.live()) {
134
+ * console.log(event.data.track?.title);
135
+ * }
136
+ * ```
137
+ */
138
+ live: (options = {}) => this.sse("/v1/streams/live", options.signal),
125
139
  /** Stop monitoring a stream. */
126
140
  stop: (id) => this.request(`/v1/streams/${id}`, { method: "DELETE" })
127
141
  };
@@ -129,6 +143,7 @@ var SonoVault = class {
129
143
  /** Register an endpoint for stream events. The response includes `secret` once — store it. */
130
144
  create: (body) => this.request("/v1/webhooks", { method: "POST", json: body }),
131
145
  list: () => this.request("/v1/webhooks"),
146
+ get: (id) => this.request(`/v1/webhooks/${id}`),
132
147
  update: (id, body) => this.request(`/v1/webhooks/${id}`, { method: "PATCH", json: body }),
133
148
  delete: (id) => this.request(`/v1/webhooks/${id}`, { method: "DELETE" }),
134
149
  test: (id) => this.request(`/v1/webhooks/${id}/test`, { method: "POST" }),
@@ -138,6 +153,7 @@ var SonoVault = class {
138
153
  this.apiKey = options.apiKey;
139
154
  this.baseUrl = (options.baseUrl ?? "https://api.sonovault.now").replace(/\/$/, "");
140
155
  this.maxRetries = options.maxRetries ?? 2;
156
+ this.timeoutMs = options.timeoutMs ?? 3e4;
141
157
  this.fetchImpl = options.fetch ?? globalThis.fetch;
142
158
  }
143
159
  async request(path, opts = {}) {
@@ -145,7 +161,10 @@ var SonoVault = class {
145
161
  for (const [key, value] of Object.entries(opts.query ?? {})) {
146
162
  if (value !== void 0) url.searchParams.set(key, String(value));
147
163
  }
148
- const headers = { "x-api-key": this.apiKey };
164
+ const headers = {
165
+ "x-api-key": this.apiKey,
166
+ "User-Agent": `sonovault-js/${VERSION}`
167
+ };
149
168
  let body;
150
169
  if (opts.json !== void 0) {
151
170
  headers["Content-Type"] = "application/json";
@@ -158,9 +177,15 @@ var SonoVault = class {
158
177
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
159
178
  let res;
160
179
  try {
161
- res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body });
180
+ const signal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : void 0;
181
+ res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body, signal });
162
182
  } catch (err) {
163
- lastError = new SonoVaultError(`Network error: ${err.message}`, 0);
183
+ const e = err;
184
+ const timedOut = e.name === "TimeoutError" || e.name === "AbortError";
185
+ lastError = new SonoVaultError(
186
+ timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
187
+ 0
188
+ );
164
189
  continue;
165
190
  }
166
191
  if (res.ok) {
@@ -178,9 +203,94 @@ var SonoVault = class {
178
203
  }
179
204
  throw lastError ?? new SonoVaultError("Request failed", 0);
180
205
  }
206
+ /** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
207
+ async *sse(path, signal) {
208
+ const url = new URL(this.baseUrl + path);
209
+ const res = await this.fetchImpl(url, {
210
+ headers: {
211
+ "x-api-key": this.apiKey,
212
+ "User-Agent": `sonovault-js/${VERSION}`,
213
+ Accept: "text/event-stream"
214
+ },
215
+ signal
216
+ });
217
+ if (!res.ok) {
218
+ const body = await res.json().catch(() => void 0);
219
+ const message = body?.error ?? `HTTP ${res.status}`;
220
+ throw new SonoVaultError(message, res.status, body);
221
+ }
222
+ if (!res.body) throw new SonoVaultError("SSE response has no body", res.status);
223
+ const reader = res.body.getReader();
224
+ const decoder = new TextDecoder();
225
+ let buffer = "";
226
+ let dataLines = [];
227
+ try {
228
+ while (true) {
229
+ const { done, value } = await reader.read();
230
+ if (done) break;
231
+ buffer += decoder.decode(value, { stream: true });
232
+ let newline;
233
+ while ((newline = buffer.indexOf("\n")) !== -1) {
234
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
235
+ buffer = buffer.slice(newline + 1);
236
+ if (line === "") {
237
+ if (dataLines.length > 0) {
238
+ const data = dataLines.join("\n");
239
+ dataLines = [];
240
+ try {
241
+ yield JSON.parse(data);
242
+ } catch {
243
+ }
244
+ }
245
+ } else if (line.startsWith("data:")) {
246
+ dataLines.push(line.slice(5).replace(/^ /, ""));
247
+ }
248
+ }
249
+ }
250
+ } finally {
251
+ reader.releaseLock();
252
+ res.body.cancel().catch(() => {
253
+ });
254
+ }
255
+ }
181
256
  };
257
+
258
+ // src/webhooks.ts
259
+ var import_node_crypto = require("crypto");
260
+ function verifyWebhookSignature(options) {
261
+ const { secret, header, payload, toleranceSeconds = 300 } = options;
262
+ if (!secret || !header) return false;
263
+ const parts = {};
264
+ for (const kv of header.split(",")) {
265
+ const i = kv.indexOf("=");
266
+ if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
267
+ }
268
+ const t = Number(parts.t);
269
+ const v1 = parts.v1;
270
+ if (!Number.isFinite(t) || !v1) return false;
271
+ if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1e3) - t) > toleranceSeconds) {
272
+ return false;
273
+ }
274
+ const body = typeof payload === "string" ? payload : payload.toString("utf8");
275
+ const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(`${t}.${body}`).digest("hex");
276
+ const a = Buffer.from(v1);
277
+ const b = Buffer.from(expected);
278
+ return a.length === b.length && (0, import_node_crypto.timingSafeEqual)(a, b);
279
+ }
280
+
281
+ // src/pagination.ts
282
+ async function* paginate(fetchPage) {
283
+ let cursor;
284
+ do {
285
+ const page = await fetchPage(cursor);
286
+ for (const item of page.results) yield item;
287
+ cursor = page.next_cursor ?? void 0;
288
+ } while (cursor);
289
+ }
182
290
  // Annotate the CommonJS export names for ESM import in node:
183
291
  0 && (module.exports = {
184
292
  SonoVault,
185
- SonoVaultError
293
+ SonoVaultError,
294
+ paginate,
295
+ verifyWebhookSignature
186
296
  });
package/dist/index.d.cts CHANGED
@@ -152,10 +152,28 @@ interface Webhook {
152
152
  id: string;
153
153
  url: string;
154
154
  event_types?: string[];
155
- /** Returned once, on creation store it to verify delivery signatures. */
155
+ /** Returned once, on creation. Store it to verify delivery signatures. */
156
156
  secret?: string;
157
157
  [key: string]: unknown;
158
158
  }
159
+ /**
160
+ * An event from the live SSE feed or a webhook delivery.
161
+ * Types: `stream.play.started`, `stream.offline`, `stream.online`.
162
+ */
163
+ interface StreamEvent {
164
+ id: string;
165
+ type: string;
166
+ created: number | string;
167
+ data: {
168
+ stream_id: string;
169
+ started_at?: string;
170
+ at?: string;
171
+ reason?: string;
172
+ track?: Track;
173
+ [key: string]: unknown;
174
+ };
175
+ [key: string]: unknown;
176
+ }
159
177
 
160
178
  interface SonoVaultOptions {
161
179
  /** Your API key — get a free one at https://sonovault.now (1,000 requests/month). */
@@ -164,6 +182,11 @@ interface SonoVaultOptions {
164
182
  baseUrl?: string;
165
183
  /** Retries on 429/5xx responses. Default 2; set 0 to disable. */
166
184
  maxRetries?: number;
185
+ /**
186
+ * Per-request timeout in milliseconds. Default 30000; set 0 to disable.
187
+ * Does not apply to `streams.live()`, which stays open indefinitely.
188
+ */
189
+ timeoutMs?: number;
167
190
  /** Custom fetch implementation (for testing or polyfills). */
168
191
  fetch?: typeof globalThis.fetch;
169
192
  }
@@ -171,9 +194,12 @@ declare class SonoVault {
171
194
  private readonly apiKey;
172
195
  private readonly baseUrl;
173
196
  private readonly maxRetries;
197
+ private readonly timeoutMs;
174
198
  private readonly fetchImpl;
175
199
  constructor(options: SonoVaultOptions);
176
200
  private request;
201
+ /** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
202
+ private sse;
177
203
  readonly tracks: {
178
204
  /** Search by artist + title (both required — there is no free-text query). */
179
205
  search: (params: {
@@ -307,8 +333,19 @@ declare class SonoVault {
307
333
  until: string;
308
334
  stream_id?: string;
309
335
  }) => Promise<Record<string, unknown>>;
310
- /** What's playing right now across your monitored streams. */
311
- live: () => Promise<Record<string, unknown>>;
336
+ /**
337
+ * Real-time play events for your monitored streams, as an async iterator
338
+ * over Server-Sent Events. Runs until you `break` or abort the signal.
339
+ *
340
+ * ```ts
341
+ * for await (const event of sv.streams.live()) {
342
+ * console.log(event.data.track?.title);
343
+ * }
344
+ * ```
345
+ */
346
+ live: (options?: {
347
+ signal?: AbortSignal;
348
+ }) => AsyncGenerator<StreamEvent, any, any>;
312
349
  /** Stop monitoring a stream. */
313
350
  stop: (id: string) => Promise<void>;
314
351
  };
@@ -322,6 +359,7 @@ declare class SonoVault {
322
359
  list: () => Promise<{
323
360
  webhooks: Webhook[];
324
361
  }>;
362
+ get: (id: string) => Promise<Webhook>;
325
363
  update: (id: string, body: Record<string, unknown>) => Promise<Webhook>;
326
364
  delete: (id: string) => Promise<void>;
327
365
  test: (id: string) => Promise<Record<string, unknown>>;
@@ -344,4 +382,45 @@ declare class SonoVaultError extends Error {
344
382
  get isRateLimited(): boolean;
345
383
  }
346
384
 
347
- export { type Artist, type Genre, type IdentifyRequest, type IdentifyResponse, type IdentifyResult, type IswcLookupResponse, type Label, type Page, type PlatformLink, type PlatformLinksResponse, type Release, type ResolveInputType, type ResolveRequest, type ResolveResponse, type ResolveResult, SonoVault, SonoVaultError, type SonoVaultOptions, type Stream, type Track, type TrackArtist, type TrackRelease, type Webhook };
385
+ /**
386
+ * Verify a `SonoVault-Signature` webhook header against the raw request body.
387
+ *
388
+ * The header format is `t=<unix>,v1=<hex>` where
389
+ * `v1 = HMAC-SHA256(secret, "<t>.<rawBody>")`. Use the `secret` returned once
390
+ * by `sv.webhooks.create()`. Compute over the RAW body bytes, before any JSON
391
+ * parsing. Constant-time compare. Rejects timestamps outside
392
+ * `toleranceSeconds` (default 300; pass 0 to disable the age check).
393
+ *
394
+ * ```ts
395
+ * app.post("/webhooks/sonovault", express.raw({ type: "application/json" }), (req, res) => {
396
+ * const ok = verifyWebhookSignature({
397
+ * secret: process.env.SONOVAULT_WEBHOOK_SECRET!,
398
+ * header: req.header("SonoVault-Signature") ?? "",
399
+ * payload: req.body,
400
+ * });
401
+ * if (!ok) return res.status(400).end();
402
+ * res.status(200).end();
403
+ * });
404
+ * ```
405
+ */
406
+ declare function verifyWebhookSignature(options: {
407
+ secret: string;
408
+ header: string;
409
+ payload: string | Buffer;
410
+ toleranceSeconds?: number;
411
+ }): boolean;
412
+
413
+ /**
414
+ * Iterate every item across all pages of a cursor-paginated endpoint.
415
+ *
416
+ * ```ts
417
+ * import { paginate } from "sonovault";
418
+ *
419
+ * for await (const release of paginate((cursor) => sv.artists.releases(42, { cursor }))) {
420
+ * console.log(release.title);
421
+ * }
422
+ * ```
423
+ */
424
+ declare function paginate<T>(fetchPage: (cursor: string | undefined) => Promise<Page<T>>): AsyncGenerator<T>;
425
+
426
+ export { type Artist, type Genre, type IdentifyRequest, type IdentifyResponse, type IdentifyResult, type IswcLookupResponse, type Label, type Page, type PlatformLink, type PlatformLinksResponse, type Release, type ResolveInputType, type ResolveRequest, type ResolveResponse, type ResolveResult, SonoVault, SonoVaultError, type SonoVaultOptions, type Stream, type StreamEvent, type Track, type TrackArtist, type TrackRelease, type Webhook, paginate, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -152,10 +152,28 @@ interface Webhook {
152
152
  id: string;
153
153
  url: string;
154
154
  event_types?: string[];
155
- /** Returned once, on creation store it to verify delivery signatures. */
155
+ /** Returned once, on creation. Store it to verify delivery signatures. */
156
156
  secret?: string;
157
157
  [key: string]: unknown;
158
158
  }
159
+ /**
160
+ * An event from the live SSE feed or a webhook delivery.
161
+ * Types: `stream.play.started`, `stream.offline`, `stream.online`.
162
+ */
163
+ interface StreamEvent {
164
+ id: string;
165
+ type: string;
166
+ created: number | string;
167
+ data: {
168
+ stream_id: string;
169
+ started_at?: string;
170
+ at?: string;
171
+ reason?: string;
172
+ track?: Track;
173
+ [key: string]: unknown;
174
+ };
175
+ [key: string]: unknown;
176
+ }
159
177
 
160
178
  interface SonoVaultOptions {
161
179
  /** Your API key — get a free one at https://sonovault.now (1,000 requests/month). */
@@ -164,6 +182,11 @@ interface SonoVaultOptions {
164
182
  baseUrl?: string;
165
183
  /** Retries on 429/5xx responses. Default 2; set 0 to disable. */
166
184
  maxRetries?: number;
185
+ /**
186
+ * Per-request timeout in milliseconds. Default 30000; set 0 to disable.
187
+ * Does not apply to `streams.live()`, which stays open indefinitely.
188
+ */
189
+ timeoutMs?: number;
167
190
  /** Custom fetch implementation (for testing or polyfills). */
168
191
  fetch?: typeof globalThis.fetch;
169
192
  }
@@ -171,9 +194,12 @@ declare class SonoVault {
171
194
  private readonly apiKey;
172
195
  private readonly baseUrl;
173
196
  private readonly maxRetries;
197
+ private readonly timeoutMs;
174
198
  private readonly fetchImpl;
175
199
  constructor(options: SonoVaultOptions);
176
200
  private request;
201
+ /** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
202
+ private sse;
177
203
  readonly tracks: {
178
204
  /** Search by artist + title (both required — there is no free-text query). */
179
205
  search: (params: {
@@ -307,8 +333,19 @@ declare class SonoVault {
307
333
  until: string;
308
334
  stream_id?: string;
309
335
  }) => Promise<Record<string, unknown>>;
310
- /** What's playing right now across your monitored streams. */
311
- live: () => Promise<Record<string, unknown>>;
336
+ /**
337
+ * Real-time play events for your monitored streams, as an async iterator
338
+ * over Server-Sent Events. Runs until you `break` or abort the signal.
339
+ *
340
+ * ```ts
341
+ * for await (const event of sv.streams.live()) {
342
+ * console.log(event.data.track?.title);
343
+ * }
344
+ * ```
345
+ */
346
+ live: (options?: {
347
+ signal?: AbortSignal;
348
+ }) => AsyncGenerator<StreamEvent, any, any>;
312
349
  /** Stop monitoring a stream. */
313
350
  stop: (id: string) => Promise<void>;
314
351
  };
@@ -322,6 +359,7 @@ declare class SonoVault {
322
359
  list: () => Promise<{
323
360
  webhooks: Webhook[];
324
361
  }>;
362
+ get: (id: string) => Promise<Webhook>;
325
363
  update: (id: string, body: Record<string, unknown>) => Promise<Webhook>;
326
364
  delete: (id: string) => Promise<void>;
327
365
  test: (id: string) => Promise<Record<string, unknown>>;
@@ -344,4 +382,45 @@ declare class SonoVaultError extends Error {
344
382
  get isRateLimited(): boolean;
345
383
  }
346
384
 
347
- export { type Artist, type Genre, type IdentifyRequest, type IdentifyResponse, type IdentifyResult, type IswcLookupResponse, type Label, type Page, type PlatformLink, type PlatformLinksResponse, type Release, type ResolveInputType, type ResolveRequest, type ResolveResponse, type ResolveResult, SonoVault, SonoVaultError, type SonoVaultOptions, type Stream, type Track, type TrackArtist, type TrackRelease, type Webhook };
385
+ /**
386
+ * Verify a `SonoVault-Signature` webhook header against the raw request body.
387
+ *
388
+ * The header format is `t=<unix>,v1=<hex>` where
389
+ * `v1 = HMAC-SHA256(secret, "<t>.<rawBody>")`. Use the `secret` returned once
390
+ * by `sv.webhooks.create()`. Compute over the RAW body bytes, before any JSON
391
+ * parsing. Constant-time compare. Rejects timestamps outside
392
+ * `toleranceSeconds` (default 300; pass 0 to disable the age check).
393
+ *
394
+ * ```ts
395
+ * app.post("/webhooks/sonovault", express.raw({ type: "application/json" }), (req, res) => {
396
+ * const ok = verifyWebhookSignature({
397
+ * secret: process.env.SONOVAULT_WEBHOOK_SECRET!,
398
+ * header: req.header("SonoVault-Signature") ?? "",
399
+ * payload: req.body,
400
+ * });
401
+ * if (!ok) return res.status(400).end();
402
+ * res.status(200).end();
403
+ * });
404
+ * ```
405
+ */
406
+ declare function verifyWebhookSignature(options: {
407
+ secret: string;
408
+ header: string;
409
+ payload: string | Buffer;
410
+ toleranceSeconds?: number;
411
+ }): boolean;
412
+
413
+ /**
414
+ * Iterate every item across all pages of a cursor-paginated endpoint.
415
+ *
416
+ * ```ts
417
+ * import { paginate } from "sonovault";
418
+ *
419
+ * for await (const release of paginate((cursor) => sv.artists.releases(42, { cursor }))) {
420
+ * console.log(release.title);
421
+ * }
422
+ * ```
423
+ */
424
+ declare function paginate<T>(fetchPage: (cursor: string | undefined) => Promise<Page<T>>): AsyncGenerator<T>;
425
+
426
+ export { type Artist, type Genre, type IdentifyRequest, type IdentifyResponse, type IdentifyResult, type IswcLookupResponse, type Label, type Page, type PlatformLink, type PlatformLinksResponse, type Release, type ResolveInputType, type ResolveRequest, type ResolveResponse, type ResolveResult, SonoVault, SonoVaultError, type SonoVaultOptions, type Stream, type StreamEvent, type Track, type TrackArtist, type TrackRelease, type Webhook, paginate, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -20,6 +20,9 @@ var SonoVaultError = class extends Error {
20
20
  }
21
21
  };
22
22
 
23
+ // src/version.ts
24
+ var VERSION = "1.2.0";
25
+
23
26
  // src/client.ts
24
27
  var SonoVault = class {
25
28
  constructor(options) {
@@ -93,8 +96,17 @@ var SonoVault = class {
93
96
  update: (id, body) => this.request(`/v1/streams/${id}`, { method: "PATCH", json: body }),
94
97
  history: (id, params = {}) => this.request(`/v1/streams/${id}/history`, { query: params }),
95
98
  report: (params) => this.request("/v1/streams/report", { query: params }),
96
- /** What's playing right now across your monitored streams. */
97
- live: () => this.request("/v1/streams/live"),
99
+ /**
100
+ * Real-time play events for your monitored streams, as an async iterator
101
+ * over Server-Sent Events. Runs until you `break` or abort the signal.
102
+ *
103
+ * ```ts
104
+ * for await (const event of sv.streams.live()) {
105
+ * console.log(event.data.track?.title);
106
+ * }
107
+ * ```
108
+ */
109
+ live: (options = {}) => this.sse("/v1/streams/live", options.signal),
98
110
  /** Stop monitoring a stream. */
99
111
  stop: (id) => this.request(`/v1/streams/${id}`, { method: "DELETE" })
100
112
  };
@@ -102,6 +114,7 @@ var SonoVault = class {
102
114
  /** Register an endpoint for stream events. The response includes `secret` once — store it. */
103
115
  create: (body) => this.request("/v1/webhooks", { method: "POST", json: body }),
104
116
  list: () => this.request("/v1/webhooks"),
117
+ get: (id) => this.request(`/v1/webhooks/${id}`),
105
118
  update: (id, body) => this.request(`/v1/webhooks/${id}`, { method: "PATCH", json: body }),
106
119
  delete: (id) => this.request(`/v1/webhooks/${id}`, { method: "DELETE" }),
107
120
  test: (id) => this.request(`/v1/webhooks/${id}/test`, { method: "POST" }),
@@ -111,6 +124,7 @@ var SonoVault = class {
111
124
  this.apiKey = options.apiKey;
112
125
  this.baseUrl = (options.baseUrl ?? "https://api.sonovault.now").replace(/\/$/, "");
113
126
  this.maxRetries = options.maxRetries ?? 2;
127
+ this.timeoutMs = options.timeoutMs ?? 3e4;
114
128
  this.fetchImpl = options.fetch ?? globalThis.fetch;
115
129
  }
116
130
  async request(path, opts = {}) {
@@ -118,7 +132,10 @@ var SonoVault = class {
118
132
  for (const [key, value] of Object.entries(opts.query ?? {})) {
119
133
  if (value !== void 0) url.searchParams.set(key, String(value));
120
134
  }
121
- const headers = { "x-api-key": this.apiKey };
135
+ const headers = {
136
+ "x-api-key": this.apiKey,
137
+ "User-Agent": `sonovault-js/${VERSION}`
138
+ };
122
139
  let body;
123
140
  if (opts.json !== void 0) {
124
141
  headers["Content-Type"] = "application/json";
@@ -131,9 +148,15 @@ var SonoVault = class {
131
148
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
132
149
  let res;
133
150
  try {
134
- res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body });
151
+ const signal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : void 0;
152
+ res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body, signal });
135
153
  } catch (err) {
136
- lastError = new SonoVaultError(`Network error: ${err.message}`, 0);
154
+ const e = err;
155
+ const timedOut = e.name === "TimeoutError" || e.name === "AbortError";
156
+ lastError = new SonoVaultError(
157
+ timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
158
+ 0
159
+ );
137
160
  continue;
138
161
  }
139
162
  if (res.ok) {
@@ -151,8 +174,93 @@ var SonoVault = class {
151
174
  }
152
175
  throw lastError ?? new SonoVaultError("Request failed", 0);
153
176
  }
177
+ /** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
178
+ async *sse(path, signal) {
179
+ const url = new URL(this.baseUrl + path);
180
+ const res = await this.fetchImpl(url, {
181
+ headers: {
182
+ "x-api-key": this.apiKey,
183
+ "User-Agent": `sonovault-js/${VERSION}`,
184
+ Accept: "text/event-stream"
185
+ },
186
+ signal
187
+ });
188
+ if (!res.ok) {
189
+ const body = await res.json().catch(() => void 0);
190
+ const message = body?.error ?? `HTTP ${res.status}`;
191
+ throw new SonoVaultError(message, res.status, body);
192
+ }
193
+ if (!res.body) throw new SonoVaultError("SSE response has no body", res.status);
194
+ const reader = res.body.getReader();
195
+ const decoder = new TextDecoder();
196
+ let buffer = "";
197
+ let dataLines = [];
198
+ try {
199
+ while (true) {
200
+ const { done, value } = await reader.read();
201
+ if (done) break;
202
+ buffer += decoder.decode(value, { stream: true });
203
+ let newline;
204
+ while ((newline = buffer.indexOf("\n")) !== -1) {
205
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
206
+ buffer = buffer.slice(newline + 1);
207
+ if (line === "") {
208
+ if (dataLines.length > 0) {
209
+ const data = dataLines.join("\n");
210
+ dataLines = [];
211
+ try {
212
+ yield JSON.parse(data);
213
+ } catch {
214
+ }
215
+ }
216
+ } else if (line.startsWith("data:")) {
217
+ dataLines.push(line.slice(5).replace(/^ /, ""));
218
+ }
219
+ }
220
+ }
221
+ } finally {
222
+ reader.releaseLock();
223
+ res.body.cancel().catch(() => {
224
+ });
225
+ }
226
+ }
154
227
  };
228
+
229
+ // src/webhooks.ts
230
+ import { createHmac, timingSafeEqual } from "crypto";
231
+ function verifyWebhookSignature(options) {
232
+ const { secret, header, payload, toleranceSeconds = 300 } = options;
233
+ if (!secret || !header) return false;
234
+ const parts = {};
235
+ for (const kv of header.split(",")) {
236
+ const i = kv.indexOf("=");
237
+ if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
238
+ }
239
+ const t = Number(parts.t);
240
+ const v1 = parts.v1;
241
+ if (!Number.isFinite(t) || !v1) return false;
242
+ if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1e3) - t) > toleranceSeconds) {
243
+ return false;
244
+ }
245
+ const body = typeof payload === "string" ? payload : payload.toString("utf8");
246
+ const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
247
+ const a = Buffer.from(v1);
248
+ const b = Buffer.from(expected);
249
+ return a.length === b.length && timingSafeEqual(a, b);
250
+ }
251
+
252
+ // src/pagination.ts
253
+ async function* paginate(fetchPage) {
254
+ let cursor;
255
+ do {
256
+ const page = await fetchPage(cursor);
257
+ for (const item of page.results) yield item;
258
+ cursor = page.next_cursor ?? void 0;
259
+ } while (cursor);
260
+ }
155
261
  export {
156
262
  SonoVault,
157
- SonoVaultError
263
+ SonoVaultError,
264
+ paginate,
265
+ verifyWebhookSignature
158
266
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonovault",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "TypeScript/Node client for the SonoVault music metadata API — ISRC, ISWC, genre, labels, release dates, and cross-platform IDs for 90M+ tracks.",
5
5
  "keywords": [
6
6
  "music",