gerdur-core 1.0.3 → 1.0.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.4 - 2026-08-31
4
+
5
+ ### Added
6
+
7
+ - `resolveDownloadUrls(tracks, qualities)` — resolve download URLs for a whole album/playlist in **one** `media.deezer.com/v1/get_url` POST (`track_tokens[]` + an ordered `formats[]` fallback), instead of one request per track re-tried per quality. ~19× faster URL resolution for a 14-track album. Returns one `ResolvedUrl | null` per input track, in order. `formatName(quality)` is also exported.
8
+ - `httpAgent` / `httpsAgent` — the shared keep-alive connection pools are now exported so downstream apps can hand them to `got`/`undici` and reuse the same sockets for downloads. `getBuffer` / `getJson` / `getText` are exported too.
9
+
10
+ ### Changed
11
+
12
+ - **Shared HTTP agents are tuned and bounded**: `keepAlive`, `maxSockets: 64` (was the Node default of `Infinity`), `maxFreeSockets: 16`, `scheduling: 'lifo'`, 30 s keep-alive. Caps a pathological fan-out from opening hundreds of sockets to `api.deezer.com`, while leaving generous headroom over the converter's concurrency.
13
+ - **In-flight request coalescing** in the API layer: concurrent identical gateway / public-API calls (e.g. every track of one album asking for the same album info while tagging) now share a single request instead of each missing the still-empty cache and hitting the wire.
14
+
3
15
  ## 1.0.3 - 2026-08-30
4
16
 
5
17
  ### Changed
@@ -6,6 +6,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.requestPublicApi = exports.requestGet = exports.requestLight = exports.request = void 0;
7
7
  const request_1 = __importDefault(require("../lib/request"));
8
8
  const cache_1 = __importDefault(require("./cache"));
9
+ /**
10
+ * In-flight request coalescing (single-flight).
11
+ *
12
+ * The LRU only helps *after* a response lands. During a batch download the
13
+ * pipeline fires many identical metadata calls at once — e.g. tagging 14 tracks
14
+ * of one album triggers 14 concurrent `album/<id>` lookups — and every one of
15
+ * them misses the still-empty cache and hits the network. Here a second caller
16
+ * for a key already in flight awaits the same promise instead, so the wire sees
17
+ * exactly one request. Entries are removed as soon as they settle; the LRU takes
18
+ * over from there.
19
+ */
20
+ const inFlight = new Map();
21
+ const coalesce = (cacheKey, fetcher) => {
22
+ const cached = cache_1.default.get(cacheKey);
23
+ if (cached) {
24
+ return Promise.resolve(cached);
25
+ }
26
+ const pending = inFlight.get(cacheKey);
27
+ if (pending) {
28
+ return pending;
29
+ }
30
+ const promise = (async () => {
31
+ try {
32
+ return await fetcher();
33
+ }
34
+ finally {
35
+ inFlight.delete(cacheKey);
36
+ }
37
+ })();
38
+ inFlight.set(cacheKey, promise);
39
+ return promise;
40
+ };
9
41
  /**
10
42
  * Make POST requests to deezer api
11
43
  * @param {Object} body post body
@@ -13,17 +45,14 @@ const cache_1 = __importDefault(require("./cache"));
13
45
  */
14
46
  const request = async (body, method) => {
15
47
  const cacheKey = method + ':' + Object.entries(body).join(':');
16
- const cache = cache_1.default.get(cacheKey);
17
- if (cache) {
18
- return cache;
19
- }
20
- const { data: { error, results }, } = await request_1.default.post('/gateway.php', body, { params: { method } });
21
- if (Object.keys(results).length > 0) {
22
- cache_1.default.set(cacheKey, results);
23
- return results;
24
- }
25
- const errorMessage = Object.entries(error).join(', ');
26
- throw new Error(errorMessage);
48
+ return coalesce(cacheKey, async () => {
49
+ const { data: { error, results }, } = await request_1.default.post('/gateway.php', body, { params: { method } });
50
+ if (Object.keys(results).length > 0) {
51
+ cache_1.default.set(cacheKey, results);
52
+ return results;
53
+ }
54
+ throw new Error(Object.entries(error).join(', '));
55
+ });
27
56
  };
28
57
  exports.request = request;
29
58
  /**
@@ -33,19 +62,16 @@ exports.request = request;
33
62
  */
34
63
  const requestLight = async (body, method) => {
35
64
  const cacheKey = method + ':' + Object.entries(body).join(':');
36
- const cache = cache_1.default.get(cacheKey);
37
- if (cache) {
38
- return cache;
39
- }
40
- const { data: { error, results }, } = await request_1.default.post('https://www.deezer.com/ajax/gw-light.php', body, {
41
- params: { method, api_version: '1.0' },
65
+ return coalesce(cacheKey, async () => {
66
+ const { data: { error, results }, } = await request_1.default.post('https://www.deezer.com/ajax/gw-light.php', body, {
67
+ params: { method, api_version: '1.0' },
68
+ });
69
+ if (Object.keys(results).length > 0) {
70
+ cache_1.default.set(cacheKey, results);
71
+ return results;
72
+ }
73
+ throw new Error(Object.entries(error).join(', '));
42
74
  });
43
- if (Object.keys(results).length > 0) {
44
- cache_1.default.set(cacheKey, results);
45
- return results;
46
- }
47
- const errorMessage = Object.entries(error).join(', ');
48
- throw new Error(errorMessage);
49
75
  };
50
76
  exports.requestLight = requestLight;
51
77
  /**
@@ -55,17 +81,14 @@ exports.requestLight = requestLight;
55
81
  */
56
82
  const requestGet = async (method, params = {}, key = 'get_request') => {
57
83
  const cacheKey = method + key;
58
- const cache = cache_1.default.get(cacheKey);
59
- if (cache) {
60
- return cache;
61
- }
62
- const { data: { error, results }, } = await request_1.default.get('/gateway.php', { params: { method, ...params } });
63
- if (Object.keys(results).length > 0) {
64
- cache_1.default.set(cacheKey, results);
65
- return results;
66
- }
67
- const errorMessage = Object.entries(error).join(', ');
68
- throw new Error(errorMessage);
84
+ return coalesce(cacheKey, async () => {
85
+ const { data: { error, results }, } = await request_1.default.get('/gateway.php', { params: { method, ...params } });
86
+ if (Object.keys(results).length > 0) {
87
+ cache_1.default.set(cacheKey, results);
88
+ return results;
89
+ }
90
+ throw new Error(Object.entries(error).join(', '));
91
+ });
69
92
  };
70
93
  exports.requestGet = requestGet;
71
94
  /**
@@ -73,16 +96,14 @@ exports.requestGet = requestGet;
73
96
  * @param {String} slug endpoint
74
97
  */
75
98
  const requestPublicApi = async (slug) => {
76
- const cache = cache_1.default.get(slug);
77
- if (cache) {
78
- return cache;
79
- }
80
- const { data } = await request_1.default.get('https://api.deezer.com' + slug);
81
- if (data.error) {
82
- const errorMessage = Object.entries(data.error).join(', ');
83
- throw new Error(errorMessage);
84
- }
85
- cache_1.default.set(slug, data);
86
- return data;
99
+ return coalesce(slug, async () => {
100
+ const { data } = await request_1.default.get('https://api.deezer.com' + slug);
101
+ if (data.error) {
102
+ const errorMessage = Object.entries(data.error).join(', ');
103
+ throw new Error(errorMessage);
104
+ }
105
+ cache_1.default.set(slug, data);
106
+ return data;
107
+ });
87
108
  };
88
109
  exports.requestPublicApi = requestPublicApi;
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export * from './api';
3
3
  export * from './converter';
4
4
  export * from './lib/decrypt';
5
5
  export * from './lib/get-url';
6
+ export { httpAgent, httpsAgent, getBuffer, getJson, getText } from './lib/http';
6
7
  export * from './metadata-writer';
package/dist/index.js CHANGED
@@ -14,11 +14,17 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.initDeezerApi = void 0;
17
+ exports.getText = exports.getJson = exports.getBuffer = exports.httpsAgent = exports.httpAgent = exports.initDeezerApi = void 0;
18
18
  var request_1 = require("./lib/request");
19
19
  Object.defineProperty(exports, "initDeezerApi", { enumerable: true, get: function () { return request_1.initDeezerApi; } });
20
20
  __exportStar(require("./api"), exports);
21
21
  __exportStar(require("./converter"), exports);
22
22
  __exportStar(require("./lib/decrypt"), exports);
23
23
  __exportStar(require("./lib/get-url"), exports);
24
+ var http_1 = require("./lib/http");
25
+ Object.defineProperty(exports, "httpAgent", { enumerable: true, get: function () { return http_1.httpAgent; } });
26
+ Object.defineProperty(exports, "httpsAgent", { enumerable: true, get: function () { return http_1.httpsAgent; } });
27
+ Object.defineProperty(exports, "getBuffer", { enumerable: true, get: function () { return http_1.getBuffer; } });
28
+ Object.defineProperty(exports, "getJson", { enumerable: true, get: function () { return http_1.getJson; } });
29
+ Object.defineProperty(exports, "getText", { enumerable: true, get: function () { return http_1.getText; } });
24
30
  __exportStar(require("./metadata-writer"), exports);
@@ -13,6 +13,8 @@ export declare class ExpiredTrackToken extends Error {
13
13
  readonly sngId: string;
14
14
  constructor(sngId: string);
15
15
  }
16
+ /** quality code -> the `format` string the media API expects */
17
+ export declare const formatName: (quality: number) => string;
16
18
  /**
17
19
  * @param track Track info json returned from `getTrackInfo`
18
20
  * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps)
@@ -22,3 +24,22 @@ export declare const getTrackDownloadUrl: (track: trackType, quality: number) =>
22
24
  isEncrypted: boolean;
23
25
  fileSize: number;
24
26
  } | null>;
27
+ export interface ResolvedUrl {
28
+ trackUrl: string;
29
+ isEncrypted: boolean;
30
+ fileSize: number;
31
+ /** the format Deezer actually returned, e.g. `'FLAC'` */
32
+ format: string;
33
+ }
34
+ /**
35
+ * Resolve download URLs for many tracks in a **single** `get_url` request.
36
+ *
37
+ * `qualities` is an ordered preference list (e.g. `[9, 3, 1]`): Deezer returns
38
+ * the best each track is licensed for, so there is no per-quality retry. The
39
+ * result has one entry per input track, in order; `null` for a track that is
40
+ * geo-blocked, unavailable, or errored.
41
+ *
42
+ * @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
43
+ * @param qualities preference order — 9 = FLAC, 3 = MP3 320, 1 = MP3 128
44
+ */
45
+ export declare const resolveDownloadUrls: (tracks: trackType[], qualities?: number[]) => Promise<(ResolvedUrl | null)[]>;
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getTrackDownloadUrl = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
6
+ exports.resolveDownloadUrls = exports.getTrackDownloadUrl = exports.formatName = exports.ExpiredTrackToken = exports.GeoBlocked = exports.WrongLicense = void 0;
7
7
  const delay_1 = __importDefault(require("delay"));
8
8
  const decrypt_1 = require("../lib/decrypt");
9
9
  const http_1 = require("../lib/http");
@@ -66,49 +66,68 @@ const dzAuthenticate = async () => {
66
66
  return user_data;
67
67
  };
68
68
  const MEDIA_MAX_RETRIES = 3;
69
- const getTrackUrlFromServer = async (track_token, format, attempt = 0) => {
70
- const user = user_data ? user_data : await dzAuthenticate();
71
- if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) {
72
- throw new WrongLicense(format);
69
+ /** quality code -> the `format` string the media API expects */
70
+ const formatName = (quality) => {
71
+ switch (quality) {
72
+ case 9:
73
+ return 'FLAC';
74
+ case 3:
75
+ return 'MP3_320';
76
+ case 1:
77
+ return 'MP3_128';
78
+ default:
79
+ throw new Error(`Unknown quality ${quality}`);
73
80
  }
74
- let data;
81
+ };
82
+ exports.formatName = formatName;
83
+ /** POST media.deezer.com/v1/get_url with re-auth + exponential-backoff retry on 403/429/5xx. */
84
+ const mediaGetUrl = async (track_tokens, formats, attempt = 0) => {
85
+ var _a;
86
+ const user = user_data ? user_data : await dzAuthenticate();
75
87
  try {
76
- ({ data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
88
+ const { data } = await request_1.default.post('https://media.deezer.com/v1/get_url', {
77
89
  license_token: user.license_token,
78
- media: [
79
- {
80
- type: 'FULL',
81
- formats: [{ format, cipher: 'BF_CBC_STRIPE' }],
82
- },
83
- ],
84
- track_tokens: [track_token],
85
- }));
90
+ media: [{ type: 'FULL', formats }],
91
+ track_tokens,
92
+ });
93
+ return { data: (_a = data.data) !== null && _a !== void 0 ? _a : [], country: user.country };
86
94
  }
87
95
  catch (err) {
88
- // media.deezer.com throttles / 5xx / stale license_token — re-auth and back off
89
96
  const status = err instanceof http_1.HttpStatusError ? err.statusCode : 0;
90
97
  if ((status === 403 || status === 429 || status >= 500) && attempt < MEDIA_MAX_RETRIES) {
91
98
  user_data = null;
92
99
  await (0, delay_1.default)(500 * 2 ** attempt + Math.floor(Math.random() * 250));
93
- return getTrackUrlFromServer(track_token, format, attempt + 1);
100
+ return mediaGetUrl(track_tokens, formats, attempt + 1);
94
101
  }
95
102
  throw err;
96
103
  }
97
- if (data.data.length > 0) {
98
- if (data.data[0].errors) {
99
- const { code } = data.data[0].errors[0];
100
- if (code === 2002) {
101
- throw new GeoBlocked(user.country);
102
- }
103
- // 2000: invalid/expired token · 2001: token has no rights on this song
104
- if (code === 2000 || code === 2001) {
105
- throw new ExpiredTrackToken(track_token);
106
- }
107
- throw new Error(Object.entries(data.data[0].errors[0]).join(', '));
108
- }
109
- return data.data[0].media.length > 0 ? data.data[0].media[0].sources[0].url : null;
104
+ };
105
+ /** Parse one `data[i]` entry from a get_url response into a source URL (or null / throw). */
106
+ const parseMediaEntry = (entry, token, country) => {
107
+ var _a, _b;
108
+ if (entry === null || entry === void 0 ? void 0 : entry.errors) {
109
+ const { code } = entry.errors[0];
110
+ if (code === 2002)
111
+ throw new GeoBlocked(country);
112
+ if (code === 2000 || code === 2001)
113
+ throw new ExpiredTrackToken(token);
114
+ throw new Error(Object.entries(entry.errors[0]).join(', '));
115
+ }
116
+ const media = (_a = entry === null || entry === void 0 ? void 0 : entry.media) === null || _a === void 0 ? void 0 : _a[0];
117
+ if (!((_b = media === null || media === void 0 ? void 0 : media.sources) === null || _b === void 0 ? void 0 : _b.length))
118
+ return null;
119
+ return { url: media.sources[0].url, format: media.format };
120
+ };
121
+ const getTrackUrlFromServer = async (track_token, format) => {
122
+ var _a, _b;
123
+ const user = user_data ? user_data : await dzAuthenticate();
124
+ if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) {
125
+ throw new WrongLicense(format);
110
126
  }
111
- return null;
127
+ const { data, country } = await mediaGetUrl([track_token], [{ format, cipher: 'BF_CBC_STRIPE' }]);
128
+ if (!data.length)
129
+ return null;
130
+ return (_b = (_a = parseMediaEntry(data[0], track_token, country)) === null || _a === void 0 ? void 0 : _a.url) !== null && _b !== void 0 ? _b : null;
112
131
  };
113
132
  /**
114
133
  * @param track Track info json returned from `getTrackInfo`
@@ -119,20 +138,7 @@ const getTrackDownloadUrl = async (track, quality) => {
119
138
  let geoBlocked = null;
120
139
  let expiredToken = null;
121
140
  let mediaBlocked = null;
122
- let formatName;
123
- switch (quality) {
124
- case 9:
125
- formatName = 'FLAC';
126
- break;
127
- case 3:
128
- formatName = 'MP3_320';
129
- break;
130
- case 1:
131
- formatName = 'MP3_128';
132
- break;
133
- default:
134
- throw new Error(`Unknown quality ${quality}`);
135
- }
141
+ const format = (0, exports.formatName)(quality);
136
142
  // Track tokens last ~1 hour. If it's already stale, skip the doomed media API
137
143
  // call and go straight to the token-free fallback below.
138
144
  const tokenStale = Boolean(track.TRACK_TOKEN_EXPIRE && track.TRACK_TOKEN_EXPIRE * 1000 < Date.now());
@@ -142,7 +148,7 @@ const getTrackDownloadUrl = async (track, quality) => {
142
148
  else {
143
149
  // Get URL with the official API
144
150
  try {
145
- const url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName);
151
+ const url = await getTrackUrlFromServer(track.TRACK_TOKEN, format);
146
152
  if (url) {
147
153
  return {
148
154
  trackUrl: url,
@@ -207,3 +213,42 @@ const testUrl = async (url) => {
207
213
  return 0;
208
214
  }
209
215
  };
216
+ const QUALITY_OF_FORMAT = { FLAC: 9, MP3_320: 3, MP3_128: 1, MP3_256: 3, MP3_64: 1 };
217
+ /**
218
+ * Resolve download URLs for many tracks in a **single** `get_url` request.
219
+ *
220
+ * `qualities` is an ordered preference list (e.g. `[9, 3, 1]`): Deezer returns
221
+ * the best each track is licensed for, so there is no per-quality retry. The
222
+ * result has one entry per input track, in order; `null` for a track that is
223
+ * geo-blocked, unavailable, or errored.
224
+ *
225
+ * @param tracks from `getTrackInfo` / `parseInfo` (needs `TRACK_TOKEN`, `SNG_ID`, `FILESIZE_*`)
226
+ * @param qualities preference order — 9 = FLAC, 3 = MP3 320, 1 = MP3 128
227
+ */
228
+ const resolveDownloadUrls = async (tracks, qualities = [9, 3, 1]) => {
229
+ if (!tracks.length)
230
+ return [];
231
+ const formats = qualities.map((q) => ({ format: (0, exports.formatName)(q), cipher: 'BF_CBC_STRIPE' }));
232
+ const { data, country } = await mediaGetUrl(tracks.map((t) => t.TRACK_TOKEN), formats);
233
+ return tracks.map((track, i) => {
234
+ var _a;
235
+ let parsed;
236
+ try {
237
+ parsed = parseMediaEntry(data[i], track.TRACK_TOKEN, country);
238
+ }
239
+ catch {
240
+ // per-track error (geo-block, no rights, expired token) — skip this one
241
+ return null;
242
+ }
243
+ if (!parsed)
244
+ return null;
245
+ const q = (_a = QUALITY_OF_FORMAT[parsed.format]) !== null && _a !== void 0 ? _a : qualities[0];
246
+ return {
247
+ trackUrl: parsed.url,
248
+ isEncrypted: parsed.url.includes('/mobile/') || parsed.url.includes('/media/'),
249
+ fileSize: getTrackFileSize(track, q),
250
+ format: parsed.format,
251
+ };
252
+ });
253
+ };
254
+ exports.resolveDownloadUrls = resolveDownloadUrls;
@@ -1,6 +1,8 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { IncomingHttpHeaders } from 'http';
3
+ /// <reference types="node" />
4
+ import { Agent as HttpAgent, IncomingHttpHeaders } from 'http';
5
+ import { Agent as HttpsAgent } from 'https';
4
6
  type HttpMethod = 'GET' | 'POST' | 'HEAD';
5
7
  type ResponseType = 'buffer' | 'json' | 'text';
6
8
  type QueryValue = string | number | boolean | null | undefined;
@@ -22,6 +24,8 @@ interface RequestDescriptor {
22
24
  timeout: number;
23
25
  url: string;
24
26
  }
27
+ export declare const httpAgent: HttpAgent;
28
+ export declare const httpsAgent: HttpsAgent;
25
29
  export interface HttpResponse<T> {
26
30
  config: RequestDescriptor;
27
31
  data: T;
package/dist/lib/http.js CHANGED
@@ -1,11 +1,31 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.headRequest = exports.getText = exports.getJson = exports.getBuffer = exports.HttpClient = exports.HttpStatusError = void 0;
3
+ exports.headRequest = exports.getText = exports.getJson = exports.getBuffer = exports.HttpClient = exports.HttpStatusError = exports.httpsAgent = exports.httpAgent = void 0;
4
4
  const http_1 = require("http");
5
5
  const https_1 = require("https");
6
6
  const zlib_1 = require("zlib");
7
- const httpAgent = new http_1.Agent({ keepAlive: true });
8
- const httpsAgent = new https_1.Agent({ keepAlive: true });
7
+ /**
8
+ * Shared keep-alive connection pools. Every request gerdur-core makes — gateway,
9
+ * media API, CDN HEAD probes — reuses these, so a batch of calls to the same host
10
+ * rides one handful of TCP+TLS connections instead of a fresh handshake each time.
11
+ *
12
+ * `maxSockets` is bounded (not the Node default of `Infinity`): the converter fans
13
+ * out at concurrency 10–25, so 64 is generous headroom while still capping a
14
+ * pathological fan-out that would otherwise open hundreds of sockets to
15
+ * `api.deezer.com` and get the account rate-limited. `scheduling: 'lifo'` keeps
16
+ * the hottest socket warm. Downstream apps can import these and hand them to
17
+ * `got`/`undici` so downloads share the same pool.
18
+ */
19
+ const AGENT_OPTS = {
20
+ keepAlive: true,
21
+ keepAliveMsecs: 30000,
22
+ maxSockets: 64,
23
+ maxFreeSockets: 16,
24
+ scheduling: 'lifo',
25
+ timeout: 60000,
26
+ };
27
+ exports.httpAgent = new http_1.Agent(AGENT_OPTS);
28
+ exports.httpsAgent = new https_1.Agent(AGENT_OPTS);
9
29
  class HttpStatusError extends Error {
10
30
  constructor(statusCode, headers, body) {
11
31
  super(`Request failed with status code ${statusCode}`);
@@ -86,7 +106,7 @@ const requestRaw = async (config, redirectCount = 0) => {
86
106
  const isHttps = requestUrl.protocol === 'https:';
87
107
  const requestFn = isHttps ? https_1.request : http_1.request;
88
108
  const req = requestFn({
89
- agent: isHttps ? httpsAgent : httpAgent,
109
+ agent: isHttps ? exports.httpsAgent : exports.httpAgent,
90
110
  headers,
91
111
  hostname: requestUrl.hostname,
92
112
  method: config.method,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gerdur-core",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Core module for gerdur.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",