fast-npm-meta 0.4.8 → 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/dist/index.d.mts CHANGED
@@ -28,6 +28,7 @@ interface PackageVersionsInfoWithMetadata extends PackageManifest {
28
28
  specifier: string;
29
29
  }
30
30
  interface PackageError {
31
+ status: number;
31
32
  name: string;
32
33
  error: string;
33
34
  }
@@ -45,6 +46,40 @@ interface ResolvedPackageVersion {
45
46
  interface ResolvedPackageVersionWithMetadata extends ResolvedPackageVersion, PackageVersionMeta {
46
47
  }
47
48
 
49
+ interface RetryOptions {
50
+ /**
51
+ * The number of times to retry the operation.
52
+ *
53
+ * @default 5
54
+ */
55
+ retries?: number;
56
+ /**
57
+ * The exponential factor to use.
58
+ *
59
+ * @default 2
60
+ */
61
+ factor?: number;
62
+ /**
63
+ * The number of milliseconds before starting the first retry.
64
+ *
65
+ * Set this to `0` to retry immediately with no delay.
66
+ *
67
+ * @default 1000
68
+ */
69
+ minTimeout?: number;
70
+ /**
71
+ * The maximum number of milliseconds between two retries.
72
+ *
73
+ * @default Infinity
74
+ */
75
+ maxTimeout?: number;
76
+ /**
77
+ * Randomizes the timeouts by multiplying with a factor between 1 and 2.
78
+ *
79
+ * @default false
80
+ */
81
+ randomize?: boolean;
82
+ }
48
83
  interface FetchOptions<Throw extends boolean = true> {
49
84
  /**
50
85
  * API endpoint for fetching package versions
@@ -90,6 +125,15 @@ interface GetVersionsOptions<Metadata extends boolean = false, Throw extends boo
90
125
  * @default undefined
91
126
  */
92
127
  after?: string;
128
+ /**
129
+ * Retry options for the built-in retry mechanism
130
+ *
131
+ * Can be:
132
+ * - `RetryOptions` object for fine-grained control
133
+ * - `number` for simple retry count (uses defaults for other options)
134
+ * - `false` to disable retries
135
+ */
136
+ retry?: RetryOptions | number | false;
93
137
  }
94
138
  interface GetLatestVersionOptions<Metadata extends boolean = false, Throw extends boolean = true> extends FetchOptions<Throw> {
95
139
  /**
@@ -104,6 +148,15 @@ interface GetLatestVersionOptions<Metadata extends boolean = false, Throw extend
104
148
  * @default false
105
149
  */
106
150
  metadata?: Metadata;
151
+ /**
152
+ * Retry options for the built-in retry mechanism
153
+ *
154
+ * Can be:
155
+ * - `RetryOptions` object for fine-grained control
156
+ * - `number` for simple retry count (uses defaults for other options)
157
+ * - `false` to disable retries
158
+ */
159
+ retry?: RetryOptions | number | false;
107
160
  }
108
161
  type InferGetVersionsResult<Metadata, Throw> = Metadata extends true ? Throw extends true ? PackageVersionsInfoWithMetadata : MaybeError<PackageVersionsInfoWithMetadata> : Throw extends true ? PackageVersionsInfo : MaybeError<PackageVersionsInfo>;
109
162
  type InferGetLatestVersionResult<Metadata, Throw> = Metadata extends true ? Throw extends true ? ResolvedPackageVersionWithMetadata : MaybeError<ResolvedPackageVersionWithMetadata> : Throw extends true ? ResolvedPackageVersion : MaybeError<ResolvedPackageVersion>;
@@ -131,4 +184,4 @@ declare const NPM_REGISTRY = "https://registry.npmjs.org/";
131
184
  declare function pickRegistry(scope: string | null | undefined, npmConfigs: Record<string, unknown>, defaultRegistry?: string): string;
132
185
 
133
186
  export { NPM_REGISTRY, defaultOptions, getLatestVersion, getLatestVersionBatch, getVersions, getVersionsBatch, pickRegistry };
134
- export type { Engines, FetchOptions, GetLatestVersionOptions, GetVersionsOptions, InferGetLatestVersionResult, InferGetVersionsResult, MaybeError, PackageError, PackageManifest, PackageManifestError, PackageVersionMeta, PackageVersionsInfo, PackageVersionsInfoWithMetadata, ResolvedPackageVersion, ResolvedPackageVersionWithMetadata };
187
+ export type { Engines, FetchOptions, GetLatestVersionOptions, GetVersionsOptions, InferGetLatestVersionResult, InferGetVersionsResult, MaybeError, PackageError, PackageManifest, PackageManifestError, PackageVersionMeta, PackageVersionsInfo, PackageVersionsInfoWithMetadata, ResolvedPackageVersion, ResolvedPackageVersionWithMetadata, RetryOptions };
package/dist/index.mjs CHANGED
@@ -1,3 +1,268 @@
1
+ const objectToString = Object.prototype.toString;
2
+
3
+ const isError = value => objectToString.call(value) === '[object Error]';
4
+
5
+ const errorMessages = new Set([
6
+ 'network error', // Chrome
7
+ 'Failed to fetch', // Chrome
8
+ 'NetworkError when attempting to fetch resource.', // Firefox
9
+ 'The Internet connection appears to be offline.', // Safari 16
10
+ 'Network request failed', // `cross-fetch`
11
+ 'fetch failed', // Undici (Node.js)
12
+ 'terminated', // Undici (Node.js)
13
+ ' A network error occurred.', // Bun (WebKit)
14
+ 'Network connection lost', // Cloudflare Workers (fetch)
15
+ ]);
16
+
17
+ function isNetworkError(error) {
18
+ const isValid = error
19
+ && isError(error)
20
+ && error.name === 'TypeError'
21
+ && typeof error.message === 'string';
22
+
23
+ if (!isValid) {
24
+ return false;
25
+ }
26
+
27
+ const {message, stack} = error;
28
+
29
+ // Safari 17+ has generic message but no stack for network errors
30
+ if (message === 'Load failed') {
31
+ return stack === undefined
32
+ // Sentry adds its own stack trace to the fetch error, so also check for that
33
+ || '__sentry_captured__' in error;
34
+ }
35
+
36
+ // Deno network errors start with specific text
37
+ if (message.startsWith('error sending request for url')) {
38
+ return true;
39
+ }
40
+
41
+ // Standard network error messages
42
+ return errorMessages.has(message);
43
+ }
44
+
45
+ function validateRetries(retries) {
46
+ if (typeof retries === 'number') {
47
+ if (retries < 0) {
48
+ throw new TypeError('Expected `retries` to be a non-negative number.');
49
+ }
50
+
51
+ if (Number.isNaN(retries)) {
52
+ throw new TypeError('Expected `retries` to be a valid number or Infinity, got NaN.');
53
+ }
54
+ } else if (retries !== undefined) {
55
+ throw new TypeError('Expected `retries` to be a number or Infinity.');
56
+ }
57
+ }
58
+
59
+ function validateNumberOption(name, value, {min = 0, allowInfinity = false} = {}) {
60
+ if (value === undefined) {
61
+ return;
62
+ }
63
+
64
+ if (typeof value !== 'number' || Number.isNaN(value)) {
65
+ throw new TypeError(`Expected \`${name}\` to be a number${allowInfinity ? ' or Infinity' : ''}.`);
66
+ }
67
+
68
+ if (!allowInfinity && !Number.isFinite(value)) {
69
+ throw new TypeError(`Expected \`${name}\` to be a finite number.`);
70
+ }
71
+
72
+ if (value < min) {
73
+ throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`);
74
+ }
75
+ }
76
+
77
+ class AbortError extends Error {
78
+ constructor(message) {
79
+ super();
80
+
81
+ if (message instanceof Error) {
82
+ this.originalError = message;
83
+ ({message} = message);
84
+ } else {
85
+ this.originalError = new Error(message);
86
+ this.originalError.stack = this.stack;
87
+ }
88
+
89
+ this.name = 'AbortError';
90
+ this.message = message;
91
+ }
92
+ }
93
+
94
+ function calculateDelay(retriesConsumed, options) {
95
+ const attempt = Math.max(1, retriesConsumed + 1);
96
+ const random = options.randomize ? (Math.random() + 1) : 1;
97
+
98
+ let timeout = Math.round(random * options.minTimeout * (options.factor ** (attempt - 1)));
99
+ timeout = Math.min(timeout, options.maxTimeout);
100
+
101
+ return timeout;
102
+ }
103
+
104
+ function calculateRemainingTime(start, max) {
105
+ if (!Number.isFinite(max)) {
106
+ return max;
107
+ }
108
+
109
+ return max - (performance.now() - start);
110
+ }
111
+
112
+ async function onAttemptFailure({error, attemptNumber, retriesConsumed, startTime, options}) {
113
+ const normalizedError = error instanceof Error
114
+ ? error
115
+ : new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`);
116
+
117
+ if (normalizedError instanceof AbortError) {
118
+ throw normalizedError.originalError;
119
+ }
120
+
121
+ const retriesLeft = Number.isFinite(options.retries)
122
+ ? Math.max(0, options.retries - retriesConsumed)
123
+ : options.retries;
124
+
125
+ const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY;
126
+
127
+ const context = Object.freeze({
128
+ error: normalizedError,
129
+ attemptNumber,
130
+ retriesLeft,
131
+ retriesConsumed,
132
+ });
133
+
134
+ await options.onFailedAttempt(context);
135
+
136
+ if (calculateRemainingTime(startTime, maxRetryTime) <= 0) {
137
+ throw normalizedError;
138
+ }
139
+
140
+ const consumeRetry = await options.shouldConsumeRetry(context);
141
+
142
+ const remainingTime = calculateRemainingTime(startTime, maxRetryTime);
143
+
144
+ if (remainingTime <= 0 || retriesLeft <= 0) {
145
+ throw normalizedError;
146
+ }
147
+
148
+ if (normalizedError instanceof TypeError && !isNetworkError(normalizedError)) {
149
+ if (consumeRetry) {
150
+ throw normalizedError;
151
+ }
152
+
153
+ options.signal?.throwIfAborted();
154
+ return false;
155
+ }
156
+
157
+ if (!await options.shouldRetry(context)) {
158
+ throw normalizedError;
159
+ }
160
+
161
+ if (!consumeRetry) {
162
+ options.signal?.throwIfAborted();
163
+ return false;
164
+ }
165
+
166
+ const delayTime = calculateDelay(retriesConsumed, options);
167
+ const finalDelay = Math.min(delayTime, remainingTime);
168
+
169
+ if (finalDelay > 0) {
170
+ await new Promise((resolve, reject) => {
171
+ const onAbort = () => {
172
+ clearTimeout(timeoutToken);
173
+ options.signal?.removeEventListener('abort', onAbort);
174
+ reject(options.signal.reason);
175
+ };
176
+
177
+ const timeoutToken = setTimeout(() => {
178
+ options.signal?.removeEventListener('abort', onAbort);
179
+ resolve();
180
+ }, finalDelay);
181
+
182
+ if (options.unref) {
183
+ timeoutToken.unref?.();
184
+ }
185
+
186
+ options.signal?.addEventListener('abort', onAbort, {once: true});
187
+ });
188
+ }
189
+
190
+ options.signal?.throwIfAborted();
191
+
192
+ return true;
193
+ }
194
+
195
+ async function pRetry(input, options = {}) {
196
+ options = {...options};
197
+
198
+ validateRetries(options.retries);
199
+
200
+ if (Object.hasOwn(options, 'forever')) {
201
+ throw new Error('The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.');
202
+ }
203
+
204
+ options.retries ??= 10;
205
+ options.factor ??= 2;
206
+ options.minTimeout ??= 1000;
207
+ options.maxTimeout ??= Number.POSITIVE_INFINITY;
208
+ options.maxRetryTime ??= Number.POSITIVE_INFINITY;
209
+ options.randomize ??= false;
210
+ options.onFailedAttempt ??= () => {};
211
+ options.shouldRetry ??= () => true;
212
+ options.shouldConsumeRetry ??= () => true;
213
+
214
+ // Validate numeric options and normalize edge cases
215
+ validateNumberOption('factor', options.factor, {min: 0, allowInfinity: false});
216
+ validateNumberOption('minTimeout', options.minTimeout, {min: 0, allowInfinity: false});
217
+ validateNumberOption('maxTimeout', options.maxTimeout, {min: 0, allowInfinity: true});
218
+ validateNumberOption('maxRetryTime', options.maxRetryTime, {min: 0, allowInfinity: true});
219
+
220
+ // Treat non-positive factor as 1 to avoid zero backoff or negative behavior
221
+ if (!(options.factor > 0)) {
222
+ options.factor = 1;
223
+ }
224
+
225
+ options.signal?.throwIfAborted();
226
+
227
+ let attemptNumber = 0;
228
+ let retriesConsumed = 0;
229
+ const startTime = performance.now();
230
+
231
+ while (Number.isFinite(options.retries) ? retriesConsumed <= options.retries : true) {
232
+ attemptNumber++;
233
+
234
+ try {
235
+ options.signal?.throwIfAborted();
236
+
237
+ const result = await input(attemptNumber);
238
+
239
+ options.signal?.throwIfAborted();
240
+
241
+ return result;
242
+ } catch (error) {
243
+ if (await onAttemptFailure({
244
+ error,
245
+ attemptNumber,
246
+ retriesConsumed,
247
+ startTime,
248
+ options,
249
+ })) {
250
+ retriesConsumed++;
251
+ }
252
+ }
253
+ }
254
+
255
+ // Should not reach here, but in case it does, throw an error
256
+ throw new Error('Retry attempts exhausted without throwing an error.');
257
+ }
258
+
259
+ const defaultRetryOptions = {
260
+ retries: 5,
261
+ factor: 2,
262
+ minTimeout: 1e3,
263
+ maxTimeout: Infinity,
264
+ randomize: false
265
+ };
1
266
  const defaultOptions = {
2
267
  /**
3
268
  * API endpoint for fetching package versions
@@ -10,7 +275,8 @@ async function getLatestVersionBatch(packages, options = {}) {
10
275
  const {
11
276
  apiEndpoint = defaultOptions.apiEndpoint,
12
277
  fetch: fetchApi = fetch,
13
- throw: throwError = true
278
+ throw: throwError = true,
279
+ retry = defaultRetryOptions
14
280
  } = options;
15
281
  let query = [
16
282
  options.force ? "force=true" : "",
@@ -19,7 +285,9 @@ async function getLatestVersionBatch(packages, options = {}) {
19
285
  ].filter(Boolean).join("&");
20
286
  if (query)
21
287
  query = `?${query}`;
22
- const data = await fetchApi(new URL(packages.join("+") + query, apiEndpoint)).then((r) => r.json());
288
+ const fetchFn = () => fetchApi(new URL(packages.join("+") + query, apiEndpoint)).then((r) => r.json());
289
+ const retryOptions = typeof retry === "number" ? { ...defaultRetryOptions, retries: retry } : retry;
290
+ const data = await (retryOptions === false ? fetchFn() : pRetry(fetchFn, retryOptions));
23
291
  const list = toArray(data);
24
292
  return throwError ? throwErrorObject(list) : list;
25
293
  }
@@ -31,7 +299,8 @@ async function getVersionsBatch(packages, options = {}) {
31
299
  const {
32
300
  apiEndpoint = defaultOptions.apiEndpoint,
33
301
  fetch: fetchApi = fetch,
34
- throw: throwError = true
302
+ throw: throwError = true,
303
+ retry = defaultRetryOptions
35
304
  } = options;
36
305
  let query = [
37
306
  options.force ? "force=true" : "",
@@ -42,7 +311,11 @@ async function getVersionsBatch(packages, options = {}) {
42
311
  ].filter(Boolean).join("&");
43
312
  if (query)
44
313
  query = `?${query}`;
45
- const data = await fetchApi(new URL(`/versions/${packages.join("+")}${query}`, apiEndpoint)).then((r) => r.json());
314
+ const fetchFn = () => fetchApi(new URL(`/versions/${packages.join("+")}${query}`, apiEndpoint)).then((r) => r.json());
315
+ const data = await (retry === false ? fetchFn() : pRetry(
316
+ fetchFn,
317
+ typeof retry === "number" ? { ...defaultRetryOptions, retries: retry } : retry
318
+ ));
46
319
  const list = toArray(data);
47
320
  return throwError ? throwErrorObject(list) : list;
48
321
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fast-npm-meta",
3
3
  "type": "module",
4
- "version": "0.4.8",
4
+ "version": "1.1.0",
5
5
  "description": "Get npm package metadata",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -23,6 +23,9 @@
23
23
  "files": [
24
24
  "dist"
25
25
  ],
26
+ "devDependencies": {
27
+ "p-retry": "^7.1.0"
28
+ },
26
29
  "scripts": {
27
30
  "build": "unbuild",
28
31
  "dev": "unbuild --stub"