reachable-url 2.0.0 → 2.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.
Files changed (3) hide show
  1. package/README.md +25 -3
  2. package/index.js +44 -12
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -33,9 +33,31 @@ The target URL to be resolved.
33
33
 
34
34
  #### options
35
35
 
36
- Same as [got#options](https://github.com/sindresorhus/got#goturl-options)
36
+ Same as [got#options](https://github.com/sindresorhus/got#goturl-options), plus:
37
37
 
38
- Passing `cache` keeps the whole body, since a cache entry is only written once the body has been read in full:
38
+ ##### maxBody
39
+
40
+ Type: `number`<br>
41
+ Default: `0`
42
+
43
+ How many bytes of the body to keep when the download would otherwise be cancelled. A non-negative integer or `Infinity`; anything else throws.
44
+
45
+ The default answers reachability from the status and headers alone, never reading a body. Ask for more when the bytes themselves decide something:
46
+
47
+ ```js
48
+ // one byte is enough to tell an image from an HTML error page served as one
49
+ const response = await reachableUrl('https://example.com/favicon.png', { maxBody: 1 })
50
+ response.body[0] === 60 // => `<`, so the server answered with markup
51
+ ```
52
+
53
+ Asking for more than one byte drops the `Range` header, since a server that honors it would answer with just that byte. `Infinity` is the extreme of that, and keeps the whole entity:
54
+
55
+ ```js
56
+ const response = await reachableUrl('https://example.com/favicon.svg', { maxBody: Infinity })
57
+ response.body // => the whole entity
58
+ ```
59
+
60
+ Passing `cache` keeps the whole body too, since a cache entry is only written once the body has been read in full:
39
61
 
40
62
  ```js
41
63
  const cache = new Map()
@@ -55,7 +77,7 @@ overrides:
55
77
 
56
78
  The [got response](https://github.com/sindresorhus/got#response), plus `requestUrl`, `redirectUrls`, `redirectStatusCodes` and the `followRedirect` in effect.
57
79
 
58
- The request asks for a single byte (`Range: bytes=0-0`). When a server ignores that and starts sending the whole entity, the download is cancelled: the status and headers already say whether the URL is reachable, so `body` is `undefined` on those responses.
80
+ By default the request asks for a single byte (`Range: bytes=0-0`, which [`maxBody`](#maxbody) drops when it wants more). When a server ignores that and starts sending the whole entity, the download is cancelled: the status and headers already say whether the URL is reachable, so `body` is `undefined` on those responses unless [`maxBody`](#maxbody) asked for some of it.
59
81
 
60
82
  A `206` that did answer the range is reported as the `200` it stands for, with `content-length` taken from `content-range`.
61
83
 
package/index.js CHANGED
@@ -67,14 +67,48 @@ const willRetry = res => {
67
67
 
68
68
  // `responseType: 'buffer'` would read the whole entity a server sent despite
69
69
  // `Range`; the status and headers already answer reachability. A cache entry is
70
- // written on `end` and a retry needs a retryable error, so both keep the body.
71
- const shouldAbortDownload = res =>
72
- !res.request.options.cache && !honoredRange(res) && !willRetry(res)
70
+ // written on `end`, a retry needs a retryable error, and `maxBody: Infinity`
71
+ // asked for the entity, so all three keep the body.
72
+ const shouldAbortDownload = (res, maxBody) =>
73
+ maxBody !== Infinity && !res.request.options.cache && !honoredRange(res) && !willRetry(res)
74
+
75
+ // Asking for a range while wanting more of the body than it holds is
76
+ // contradictory: a server honoring it would answer with that one byte.
77
+ const withoutRange = opts => ({ ...opts, headers: { ...opts.headers, Range: undefined } })
78
+
79
+ const cancelDownload = (req, res) => {
80
+ // `phases.total` is filled on `end`, which a cancelled request never emits,
81
+ // and the timer does not record the cancel either.
82
+ res.timings.phases.total = Date.now() - res.timings.start
83
+ req.cancel()
84
+ }
85
+
86
+ const keepFirstBytes = (req, res, maxBody) => {
87
+ const chunks = []
88
+ let read = 0
89
+ // The cancel is not immediate, so chunks already in flight keep arriving.
90
+ const onData = chunk => {
91
+ chunks.push(chunk)
92
+ if ((read += chunk.length) < maxBody) return
93
+ res.off('data', onData)
94
+ res.body = Buffer.concat(chunks, maxBody)
95
+ cancelDownload(req, res)
96
+ }
97
+ res.on('data', onData)
98
+ }
99
+
100
+ // The count reaches `Buffer.concat` inside a stream callback, where a throw is
101
+ // uncatchable, so a value it cannot take is refused before the socket opens.
102
+ const assertMaxBody = maxBody => {
103
+ if (maxBody === Infinity || (Number.isInteger(maxBody) && maxBody >= 0)) return
104
+ throw new TypeError('`maxBody` needs to be a non-negative integer or `Infinity`.')
105
+ }
73
106
 
74
- const reachableUrl = async (url, opts) => {
75
- if (opts?.cache) assertCacheSupport()
76
- const followRedirect = opts?.followRedirect ?? got.defaults.options.followRedirect
77
- const req = got(url, opts)
107
+ const reachableUrl = async (url, { maxBody = 0, ...opts } = {}) => {
108
+ assertMaxBody(maxBody)
109
+ if (opts.cache) assertCacheSupport()
110
+ const followRedirect = opts.followRedirect ?? got.defaults.options.followRedirect
111
+ const req = got(url, maxBody > RANGE_LENGTH ? withoutRange(opts) : opts)
78
112
 
79
113
  const redirectStatusCodes = []
80
114
  const redirectUrls = []
@@ -82,11 +116,9 @@ const reachableUrl = async (url, opts) => {
82
116
 
83
117
  req.on('response', res => {
84
118
  response = res
85
- if (!shouldAbortDownload(res)) return
86
- // `phases.total` is filled on `end`, which a cancelled request never emits,
87
- // and the timer does not record the cancel either.
88
- res.timings.phases.total = Date.now() - res.timings.start
89
- req.cancel()
119
+ if (!shouldAbortDownload(res, maxBody)) return
120
+ if (maxBody === 0) return cancelDownload(req, res)
121
+ keepFirstBytes(req, res, maxBody)
90
122
  })
91
123
 
92
124
  req.on('redirect', res => {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "reachable-url",
3
3
  "description": "Resolve a URL as fast as possible and report whether the destination is reachable over HTTP.",
4
4
  "homepage": "https://github.com/Kikobeats/reachable-url",
5
- "version": "2.0.0",
5
+ "version": "2.1.0",
6
6
  "main": "index.js",
7
7
  "author": {
8
8
  "email": "josefrancisco.verdu@gmail.com",
@@ -50,7 +50,7 @@
50
50
  "standard-version": "latest"
51
51
  },
52
52
  "engines": {
53
- "node": ">=8"
53
+ "node": ">=14"
54
54
  },
55
55
  "files": [
56
56
  "index.js"