sonovault 1.0.0 → 1.1.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
 
@@ -32,16 +32,16 @@ console.log(results[0].genre, results[0].releases[0]?.label?.name);
32
32
  // Resolve that ISRC to its ID on every platform
33
33
  const { links } = await sv.tracks.links({ isrc: "GBDUW0000053" });
34
34
  for (const link of links) {
35
- console.log(link.source, link.url); // spotify https://open.spotify.com/track/…
35
+ console.log(link.source, link.url); // spotify https://open.spotify.com/track/...
36
36
  }
37
37
 
38
- // Recording composition (ISWC), for royalty/publishing workflows
38
+ // Recording to composition (ISWC), for royalty and publishing workflows
39
39
  const work = await sv.tracks.iswc({ isrc: "GBDUW0000053" });
40
40
  ```
41
41
 
42
42
  ## Bulk resolve
43
43
 
44
- Resolve up to 100 lines track names, ISRCs, or platform IDs in one request (great for enriching play logs and library exports):
44
+ Resolve up to 100 lines in one request: track names, ISRCs, or platform IDs. Useful for enriching play logs and library exports.
45
45
 
46
46
  ```ts
47
47
  const batch = await sv.tracks.resolve({
@@ -59,13 +59,13 @@ for (const row of batch.results) {
59
59
 
60
60
  ## Pagination
61
61
 
62
- List endpoints return `{ results, next_cursor }` pass the cursor back to get the next page (`next_cursor` is `null` on the last page):
62
+ List endpoints return `{ results, next_cursor }`. Pass the cursor back to get the next page. `next_cursor` is `null` on the last page.
63
63
 
64
64
  ```ts
65
65
  let cursor: string | undefined;
66
66
  do {
67
67
  const page = await sv.artists.releases(42, { cursor });
68
- // use page.results
68
+ // ...use page.results
69
69
  cursor = page.next_cursor ?? undefined;
70
70
  } while (cursor);
71
71
  ```
@@ -86,7 +86,7 @@ try {
86
86
  }
87
87
  ```
88
88
 
89
- Rate-limited responses that carry a `Retry-After` header are retried automatically (twice by default; configure with `maxRetries`).
89
+ Rate-limited responses that carry a `Retry-After` header are retried automatically. The default is 2 retries, configurable with `maxRetries`.
90
90
 
91
91
  ## API coverage
92
92
 
@@ -99,15 +99,15 @@ Rate-limited responses that carry a `Retry-After` header are retried automatical
99
99
  | `sv.genres` | `list` |
100
100
  | `sv.suggestions` | `submit`, `list` |
101
101
  | `sv.streams` | `create`, `list`, `get`, `update`, `history`, `report`, `live`, `stop` |
102
- | `sv.webhooks` | `create`, `list`, `update`, `delete`, `test`, `deliveries` |
102
+ | `sv.webhooks` | `create`, `list`, `get`, `update`, `delete`, `test`, `deliveries` |
103
103
 
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.
104
+ 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
105
 
106
106
  ## Related
107
107
 
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
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) and [ISWC lookup](https://sonovault.now/iswc-lookup). Browser tools built on the same API.
111
111
 
112
112
  ## License
113
113
 
package/dist/index.cjs CHANGED
@@ -21,7 +21,8 @@ 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
+ verifyWebhookSignature: () => verifyWebhookSignature
25
26
  });
26
27
  module.exports = __toCommonJS(index_exports);
27
28
 
@@ -47,6 +48,9 @@ var SonoVaultError = class extends Error {
47
48
  }
48
49
  };
49
50
 
51
+ // src/version.ts
52
+ var VERSION = "1.1.0";
53
+
50
54
  // src/client.ts
51
55
  var SonoVault = class {
52
56
  constructor(options) {
@@ -120,8 +124,17 @@ var SonoVault = class {
120
124
  update: (id, body) => this.request(`/v1/streams/${id}`, { method: "PATCH", json: body }),
121
125
  history: (id, params = {}) => this.request(`/v1/streams/${id}/history`, { query: params }),
122
126
  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"),
127
+ /**
128
+ * Real-time play events for your monitored streams, as an async iterator
129
+ * over Server-Sent Events. Runs until you `break` or abort the signal.
130
+ *
131
+ * ```ts
132
+ * for await (const event of sv.streams.live()) {
133
+ * console.log(event.data.track?.title);
134
+ * }
135
+ * ```
136
+ */
137
+ live: (options = {}) => this.sse("/v1/streams/live", options.signal),
125
138
  /** Stop monitoring a stream. */
126
139
  stop: (id) => this.request(`/v1/streams/${id}`, { method: "DELETE" })
127
140
  };
@@ -129,6 +142,7 @@ var SonoVault = class {
129
142
  /** Register an endpoint for stream events. The response includes `secret` once — store it. */
130
143
  create: (body) => this.request("/v1/webhooks", { method: "POST", json: body }),
131
144
  list: () => this.request("/v1/webhooks"),
145
+ get: (id) => this.request(`/v1/webhooks/${id}`),
132
146
  update: (id, body) => this.request(`/v1/webhooks/${id}`, { method: "PATCH", json: body }),
133
147
  delete: (id) => this.request(`/v1/webhooks/${id}`, { method: "DELETE" }),
134
148
  test: (id) => this.request(`/v1/webhooks/${id}/test`, { method: "POST" }),
@@ -138,6 +152,7 @@ var SonoVault = class {
138
152
  this.apiKey = options.apiKey;
139
153
  this.baseUrl = (options.baseUrl ?? "https://api.sonovault.now").replace(/\/$/, "");
140
154
  this.maxRetries = options.maxRetries ?? 2;
155
+ this.timeoutMs = options.timeoutMs ?? 3e4;
141
156
  this.fetchImpl = options.fetch ?? globalThis.fetch;
142
157
  }
143
158
  async request(path, opts = {}) {
@@ -145,7 +160,10 @@ var SonoVault = class {
145
160
  for (const [key, value] of Object.entries(opts.query ?? {})) {
146
161
  if (value !== void 0) url.searchParams.set(key, String(value));
147
162
  }
148
- const headers = { "x-api-key": this.apiKey };
163
+ const headers = {
164
+ "x-api-key": this.apiKey,
165
+ "User-Agent": `sonovault-js/${VERSION}`
166
+ };
149
167
  let body;
150
168
  if (opts.json !== void 0) {
151
169
  headers["Content-Type"] = "application/json";
@@ -158,9 +176,15 @@ var SonoVault = class {
158
176
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
159
177
  let res;
160
178
  try {
161
- res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body });
179
+ const signal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : void 0;
180
+ res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body, signal });
162
181
  } catch (err) {
163
- lastError = new SonoVaultError(`Network error: ${err.message}`, 0);
182
+ const e = err;
183
+ const timedOut = e.name === "TimeoutError" || e.name === "AbortError";
184
+ lastError = new SonoVaultError(
185
+ timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
186
+ 0
187
+ );
164
188
  continue;
165
189
  }
166
190
  if (res.ok) {
@@ -178,9 +202,83 @@ var SonoVault = class {
178
202
  }
179
203
  throw lastError ?? new SonoVaultError("Request failed", 0);
180
204
  }
205
+ /** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
206
+ async *sse(path, signal) {
207
+ const url = new URL(this.baseUrl + path);
208
+ const res = await this.fetchImpl(url, {
209
+ headers: {
210
+ "x-api-key": this.apiKey,
211
+ "User-Agent": `sonovault-js/${VERSION}`,
212
+ Accept: "text/event-stream"
213
+ },
214
+ signal
215
+ });
216
+ if (!res.ok) {
217
+ const body = await res.json().catch(() => void 0);
218
+ const message = body?.error ?? `HTTP ${res.status}`;
219
+ throw new SonoVaultError(message, res.status, body);
220
+ }
221
+ if (!res.body) throw new SonoVaultError("SSE response has no body", res.status);
222
+ const reader = res.body.getReader();
223
+ const decoder = new TextDecoder();
224
+ let buffer = "";
225
+ let dataLines = [];
226
+ try {
227
+ while (true) {
228
+ const { done, value } = await reader.read();
229
+ if (done) break;
230
+ buffer += decoder.decode(value, { stream: true });
231
+ let newline;
232
+ while ((newline = buffer.indexOf("\n")) !== -1) {
233
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
234
+ buffer = buffer.slice(newline + 1);
235
+ if (line === "") {
236
+ if (dataLines.length > 0) {
237
+ const data = dataLines.join("\n");
238
+ dataLines = [];
239
+ try {
240
+ yield JSON.parse(data);
241
+ } catch {
242
+ }
243
+ }
244
+ } else if (line.startsWith("data:")) {
245
+ dataLines.push(line.slice(5).replace(/^ /, ""));
246
+ }
247
+ }
248
+ }
249
+ } finally {
250
+ reader.releaseLock();
251
+ res.body.cancel().catch(() => {
252
+ });
253
+ }
254
+ }
181
255
  };
256
+
257
+ // src/webhooks.ts
258
+ var import_node_crypto = require("crypto");
259
+ function verifyWebhookSignature(options) {
260
+ const { secret, header, payload, toleranceSeconds = 300 } = options;
261
+ if (!secret || !header) return false;
262
+ const parts = {};
263
+ for (const kv of header.split(",")) {
264
+ const i = kv.indexOf("=");
265
+ if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
266
+ }
267
+ const t = Number(parts.t);
268
+ const v1 = parts.v1;
269
+ if (!Number.isFinite(t) || !v1) return false;
270
+ if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1e3) - t) > toleranceSeconds) {
271
+ return false;
272
+ }
273
+ const body = typeof payload === "string" ? payload : payload.toString("utf8");
274
+ const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(`${t}.${body}`).digest("hex");
275
+ const a = Buffer.from(v1);
276
+ const b = Buffer.from(expected);
277
+ return a.length === b.length && (0, import_node_crypto.timingSafeEqual)(a, b);
278
+ }
182
279
  // Annotate the CommonJS export names for ESM import in node:
183
280
  0 && (module.exports = {
184
281
  SonoVault,
185
- SonoVaultError
282
+ SonoVaultError,
283
+ verifyWebhookSignature
186
284
  });
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,32 @@ 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
+ 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, 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,32 @@ 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
+ 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, 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.1.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,82 @@ 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
+ }
155
251
  export {
156
252
  SonoVault,
157
- SonoVaultError
253
+ SonoVaultError,
254
+ verifyWebhookSignature
158
255
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonovault",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",