reachable-url 1.8.4 → 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 +76 -1
  2. package/index.js +113 -18
  3. package/package.json +3 -4
package/README.md CHANGED
@@ -33,7 +33,82 @@ 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
+
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:
61
+
62
+ ```js
63
+ const cache = new Map()
64
+ const response = await reachableUrl('https://example.com/video.mp4', { cache })
65
+ response.body // => the whole entity, so it can be cached
66
+ ```
67
+
68
+ `cache` needs [@kikobeats/cacheable-request](https://github.com/Kikobeats/cacheable-request): the version [got](https://github.com/sindresorhus/got) pulls in never settles when the origin keeps the connection alive, and no timeout recovers from it. Declare the override, otherwise passing `cache` throws:
69
+
70
+ ```yaml
71
+ # pnpm-workspace.yaml
72
+ overrides:
73
+ got>cacheable-request: npm:@kikobeats/cacheable-request
74
+ ```
75
+
76
+ #### returns
77
+
78
+ The [got response](https://github.com/sindresorhus/got#response), plus `requestUrl`, `redirectUrls`, `redirectStatusCodes` and the `followRedirect` in effect.
79
+
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.
81
+
82
+ A `206` that did answer the range is reported as the `200` it stands for, with `content-length` taken from `content-range`.
83
+
84
+ ### reachableUrl.isReachable(response)
85
+
86
+ #### response
87
+
88
+ *Required*<br>
89
+ Type: `object`
90
+
91
+ The response returned by `reachableUrl`, which echoes back `followRedirect` so it can be handed straight over.
92
+
93
+ A URL is reachable when the response is a final 2xx.
94
+
95
+ A redirect status is the final answer only when redirects were not being followed:
96
+
97
+ ```js
98
+ const response = await reachableUrl('https://example.com', { followRedirect: false })
99
+ reachableUrl.isReachable(response) // => true, the 3xx is the destination
100
+ ```
101
+
102
+ With redirect following on (the default), a 3xx is the hop the follow stopped at (a `beforeRedirect` hook threw, `maxRedirects` ran out), meaning the URL was never reached:
103
+
104
+ ```js
105
+ const response = await reachableUrl('https://example.com', {
106
+ hooks: { beforeRedirect: [() => { throw new Error('refused') }] }
107
+ })
108
+ reachableUrl.isReachable(response) // => false
109
+ ```
110
+
111
+ A partial object missing `followRedirect` is judged as if redirects were being followed, so a bare `{ statusCode: 302 }` is unreachable.
37
112
 
38
113
  ## License
39
114
 
package/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  'use strict'
2
2
 
3
- const pReflect = require('p-reflect')
4
3
  const { URL } = require('url')
5
4
 
5
+ const RANGE_LENGTH = 1
6
+
6
7
  const got = require('got').extend({
7
8
  decompress: false,
8
9
  responseType: 'buffer',
9
10
  retry: 1,
10
11
  headers: {
11
- Range: 'bytes=0-0'
12
+ Range: `bytes=0-${RANGE_LENGTH - 1}`
12
13
  },
13
14
  hooks: {
14
15
  beforeRetry: [
@@ -19,16 +20,95 @@ const got = require('got').extend({
19
20
  }
20
21
  })
21
22
 
22
- const mergeResponse = (responseOrigin = {}, responseDestination = {}) => ({
23
+ // `headers` is a prototype getter on IncomingMessage, so spreading never carries it.
24
+ const toResponse = response => ({
23
25
  statusMessage: 'Not Found',
24
26
  statusCode: 404,
25
- headers: { ...responseOrigin.headers, ...responseDestination.headers },
26
- ...responseOrigin,
27
- ...responseDestination
27
+ ...response,
28
+ headers: { ...response?.headers }
28
29
  })
29
30
 
30
- const reachableUrl = async (url, opts) => {
31
- const req = got(url, opts)
31
+ const CACHE_ERROR = `The \`cache\` option needs @kikobeats/cacheable-request.
32
+
33
+ got resolved the unpatched cacheable-request@7, which never settles when the
34
+ origin keeps the connection alive, and no timeout recovers from it. Add the
35
+ override to your pnpm-workspace.yaml:
36
+
37
+ overrides:
38
+ got>cacheable-request: npm:@kikobeats/cacheable-request`
39
+
40
+ const loadCacheableRequestManifest = () =>
41
+ require(require.resolve('cacheable-request/package.json', { paths: [require.resolve('got')] }))
42
+
43
+ // The override is an alias, which keeps the real package name, so the manifest says
44
+ // which one got resolved. Only a positive match rejects: the lookup can legitimately
45
+ // fail under a bundler, and refusing to run then is worse than missing the warning.
46
+ const assertCacheSupport = (load = loadCacheableRequestManifest) => {
47
+ let name
48
+ try {
49
+ name = load().name
50
+ } catch {}
51
+ if (name === 'cacheable-request') throw new TypeError(CACHE_ERROR)
52
+ }
53
+
54
+ const honoredRange = res =>
55
+ res.statusCode === 206 && Number(res.headers['content-length']) <= RANGE_LENGTH
56
+
57
+ const willRetry = res => {
58
+ const { retry, method } = res.request.options
59
+ return (
60
+ res.retryCount < retry.limit &&
61
+ retry.methods.includes(method) &&
62
+ retry.statusCodes.includes(res.statusCode) &&
63
+ // got declines a 413 outright unless it carries a `retry-after`.
64
+ (res.statusCode !== 413 || res.headers['retry-after'] !== undefined)
65
+ )
66
+ }
67
+
68
+ // `responseType: 'buffer'` would read the whole entity a server sent despite
69
+ // `Range`; the status and headers already answer reachability. A cache entry is
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
+ }
106
+
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)
32
112
 
33
113
  const redirectStatusCodes = []
34
114
  const redirectUrls = []
@@ -36,6 +116,9 @@ const reachableUrl = async (url, opts) => {
36
116
 
37
117
  req.on('response', res => {
38
118
  response = res
119
+ if (!shouldAbortDownload(res, maxBody)) return
120
+ if (maxBody === 0) return cancelDownload(req, res)
121
+ keepFirstBytes(req, res, maxBody)
39
122
  })
40
123
 
41
124
  req.on('redirect', res => {
@@ -43,34 +126,45 @@ const reachableUrl = async (url, opts) => {
43
126
  redirectStatusCodes.push(res.statusCode)
44
127
  })
45
128
 
46
- const { isFulfilled, value, reason: error } = await pReflect(req)
129
+ let errorResponse
130
+ try {
131
+ await req
132
+ } catch (error) {
133
+ errorResponse = error.response
134
+ }
47
135
 
48
- const mergedResponse = mergeResponse(isFulfilled ? value : error.response, response)
136
+ // A retried or cancelled request settles with the attempt before it, whose body
137
+ // is not this response's, so a response we saw outranks the error's. The error
138
+ // still carries one when nothing else did, as MaxRedirects does.
139
+ const resolvedResponse = toResponse(response ?? errorResponse)
49
140
 
50
- if (mergedResponse.statusCode === 206) {
51
- const contentRange = mergedResponse.headers['content-range']
141
+ if (resolvedResponse.statusCode === 206) {
142
+ const contentRange = resolvedResponse.headers['content-range']
52
143
  if (typeof contentRange === 'string') {
53
144
  let contentLength = contentRange.split('/')
54
145
  if (contentLength.length > 1) {
55
146
  contentLength = contentLength[contentLength.length - 1]
56
- mergedResponse.statusCode = 200
57
- mergedResponse.statusMessage = 'OK'
58
- mergedResponse.headers['content-length'] = contentLength
59
- mergedResponse.headers['content-range'] = undefined
147
+ resolvedResponse.statusCode = 200
148
+ resolvedResponse.statusMessage = 'OK'
149
+ resolvedResponse.headers['content-length'] = contentLength
150
+ resolvedResponse.headers['content-range'] = undefined
60
151
  }
61
152
  }
62
153
  }
63
154
 
64
155
  return {
65
156
  url,
66
- ...mergedResponse,
157
+ ...resolvedResponse,
158
+ followRedirect,
67
159
  redirectUrls,
68
160
  redirectStatusCodes,
69
161
  requestUrl: url
70
162
  }
71
163
  }
72
164
 
73
- const isReachable = ({ statusCode }) => statusCode >= 200 && statusCode < 400
165
+ // While following, a 3xx is the hop the follow stopped at, never the destination.
166
+ const isReachable = ({ statusCode, followRedirect = true }) =>
167
+ statusCode >= 200 && statusCode < (followRedirect ? 300 : 400)
74
168
 
75
169
  module.exports = async (url, opts) => {
76
170
  if (/^\/\//.test(url)) url = `https:${url}`
@@ -78,3 +172,4 @@ module.exports = async (url, opts) => {
78
172
  }
79
173
 
80
174
  module.exports.isReachable = isReachable
175
+ module.exports.assertCacheSupport = assertCacheSupport
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": "1.8.4",
5
+ "version": "2.1.0",
6
6
  "main": "index.js",
7
7
  "author": {
8
8
  "email": "josefrancisco.verdu@gmail.com",
@@ -30,8 +30,7 @@
30
30
  "url"
31
31
  ],
32
32
  "dependencies": {
33
- "got": "~11.8.0",
34
- "p-reflect": "~2.1.0"
33
+ "got": "~11.8.0"
35
34
  },
36
35
  "devDependencies": {
37
36
  "@commitlint/cli": "latest",
@@ -51,7 +50,7 @@
51
50
  "standard-version": "latest"
52
51
  },
53
52
  "engines": {
54
- "node": ">=8"
53
+ "node": ">=14"
55
54
  },
56
55
  "files": [
57
56
  "index.js"