@util.js/node-http 0.41.8 → 0.42.1

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/index.js +2 -3
  2. package/lib/Urls.js +396 -131
  3. package/package.json +8 -3
package/index.js CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
1
+ import Urls from "./lib/Urls.js";
2
2
 
3
- const Urls = require("./lib/Urls");
4
- module.exports = new Urls();
3
+ export default Urls;
package/lib/Urls.js CHANGED
@@ -1,154 +1,419 @@
1
- "use strict";
1
+ import https from "node:https";
2
+ import http from "node:http";
3
+ import { pipeline } from "node:stream/promises";
4
+ import Files from "@util.js/node-files";
5
+
6
+ // These are global variables for Chromium version management.
7
+ let latestChromiumAndroidStableVersion;
8
+ let latestChromiumAndroidStableVersionLocked = false;
9
+ let googleBotUserAgentHeaderLogged = false;
10
+
11
+ /**
12
+ * Determine timeout value in milliseconds from the parameters object.
13
+ */
14
+ function determineTimeout(params) {
15
+ if (params == null) return -1;
16
+ const timeoutVal = params?.timeoutMs;
17
+ const parsedInt = Number.parseInt(timeoutVal, 10);
18
+ if (timeoutVal != null && !Number.isNaN(parsedInt) && parsedInt >= 0) {
19
+ return parsedInt;
20
+ }
21
+ return -1;
22
+ }
23
+
24
+ /**
25
+ * Fetch the latest Chromium Android stable version from the API.
26
+ */
27
+ async function fetchLatestChromiumAndroidStableVersion() {
28
+ const CHANNEL = "Stable";
29
+ const url = `https://chromiumdash.appspot.com/fetch_releases?platform=Android&channel=${CHANNEL}&num=1`;
30
+
31
+ const response = await fetch(url);
32
+ if (!response.ok)
33
+ throw new Error(
34
+ `Fetching ${url} failed with HTTP status ${response.status}.`,
35
+ );
36
+
37
+ const data = await response.json();
38
+ if (!data || data.length === 0)
39
+ throw new Error("The Chromium Dash API returned an empty response.");
40
+
41
+ const latest = data[0];
42
+ if (!latest || !latest.version)
43
+ throw new Error(
44
+ "The version field is missing in the Chromium Dash API response.",
45
+ );
46
+ return latest.version;
47
+ }
48
+
49
+ /**
50
+ * Get the Google Bot user agent header with the latest Chromium version.
51
+ */
52
+ async function getGoogleBotUserAgentHeader(logger) {
53
+ // https://chromiumdash.appspot.com/releases?platform=Android
54
+ // https://github.com/wasi-master/13ft/blob/main/app/portable.py
55
+ // https://web.archive.org/web/20260811103458/https://developers.google.com/crawling/docs/crawlers-fetchers/google-common-crawlers#googlebot-news#googlebot-news
56
+
57
+ const latestChromiumAndroidStableVersion =
58
+ await getLatestChromiumAndroidStableVersion(logger);
59
+ const userAgent = `Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${latestChromiumAndroidStableVersion} Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)`;
60
+
61
+ if (!googleBotUserAgentHeaderLogged) {
62
+ googleBotUserAgentHeaderLogged = true;
63
+ logger?.error?.("Using the following user agent for HTTP requests: ");
64
+ logger?.error?.(userAgent);
65
+ logger?.error?.();
66
+ }
67
+
68
+ return userAgent;
69
+ }
70
+
71
+ /**
72
+ * Get the latest Chromium Android stable version with fallback support.
73
+ */
74
+ async function getLatestChromiumAndroidStableVersion(logger) {
75
+ while (latestChromiumAndroidStableVersionLocked) {
76
+ await new Promise((resolve) => setTimeout(resolve, 10));
77
+ }
78
+ latestChromiumAndroidStableVersionLocked = true;
79
+ try {
80
+ if (latestChromiumAndroidStableVersion)
81
+ return latestChromiumAndroidStableVersion;
82
+
83
+ try {
84
+ latestChromiumAndroidStableVersion =
85
+ await fetchLatestChromiumAndroidStableVersion();
86
+ } catch (fetchErr) {
87
+ const fallbackVersion = "152.0.7977.42";
88
+ logger?.error?.(
89
+ `Fetching the latest Chromium version failed: ${fetchErr.message}`,
90
+ );
91
+ logger?.error?.(
92
+ `Falling back to hardcoded version '${fallbackVersion}'...`,
93
+ );
94
+ latestChromiumAndroidStableVersion = fallbackVersion;
95
+ }
96
+
97
+ logger?.error?.();
98
+ logger?.error?.(
99
+ `The latest Chromium Android stable version is '${latestChromiumAndroidStableVersion}'.`,
100
+ );
101
+ logger?.error?.();
102
+ return latestChromiumAndroidStableVersion;
103
+ } finally {
104
+ latestChromiumAndroidStableVersionLocked = false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Normalize the input into a valid URL object.
110
+ */
111
+ function parseUrl(url) {
112
+ try {
113
+ return typeof url === "string" ? new URL(url) : url;
114
+ } catch (err) {
115
+ throw new TypeError(`The provided URL "${url}" is invalid.`);
116
+ }
117
+ }
2
118
 
3
119
  class Urls {
4
- decodeURI() {
5
- return decodeURI(...arguments);
6
- }
7
-
8
- decodeURIComponent() {
9
- return decodeURIComponent(...arguments);
10
- }
11
-
12
- // http://stackoverflow.com/questions/11944932/how-to-download-a-file-with-node-js
13
- download(url, destination, callbackOrOptions, callback) {
14
- if (typeof arguments[arguments.length - 1] !== "function")
15
- return promises().callCallback(this, this.download, ...arguments);
16
- const cb = arguments[arguments.length - 1];
17
- const parsedUrl = URL ? new URL(url) : urlUtil().parse(url);
18
- const file = files().createWriteStream(destination);
19
- const options = callbackOrOptions
20
- ? typeof callbackOrOptions === "function"
21
- ? {}
22
- : callbackOrOptions
23
- : {};
24
- const request = getProtocolObject(parsedUrl.protocol)
25
- .get(url, options, (response) => {
26
- response.pipe(file);
27
- file.on("finish", () => file.close(cb));
28
- })
29
- .on("error", (error) => {
30
- files().unlink(destination, (unlinkError) => {
31
- cb(unlinkError || error);
32
- });
120
+ /**
121
+ * Decode a Uniform Resource Identifier (URI).
122
+ */
123
+ static decodeURI(...args) {
124
+ return decodeURI(...args);
125
+ }
126
+
127
+ /**
128
+ * Decode a Uniform Resource Identifier (URI) component.
129
+ */
130
+ static decodeURIComponent(...args) {
131
+ return decodeURIComponent(...args);
132
+ }
133
+
134
+ /**
135
+ * Encode a Uniform Resource Identifier (URI).
136
+ */
137
+ static encodeURI(...args) {
138
+ return encodeURI(...args);
139
+ }
140
+
141
+ /**
142
+ * Encode a Uniform Resource Identifier (URI) component.
143
+ */
144
+ static encodeURIComponent(...args) {
145
+ return encodeURIComponent(...args);
146
+ }
147
+
148
+ /**
149
+ * Get HTTP headers for the given URL using a HEAD request.
150
+ * http://stackoverflow.com/questions/5922842/getting-http-headers-with-node-js
151
+ * http://stackoverflow.com/a/6001507
152
+ */
153
+ static async headers(params) {
154
+ const timeoutMs = determineTimeout(params);
155
+ const {
156
+ headers: customHeaders = {},
157
+ logger,
158
+ url,
159
+ useGoogleBotUserAgent = false,
160
+ } = params || {};
161
+
162
+ const urlObj = parseUrl(url);
163
+ const p = urlObj.protocol === "http:" ? http : https;
164
+ let headers = { ...customHeaders };
165
+
166
+ if (useGoogleBotUserAgent && !headers["User-Agent"]) {
167
+ headers["User-Agent"] = await getGoogleBotUserAgentHeader(logger);
168
+ }
169
+
170
+ const reqOptions = {
171
+ ...params,
172
+ timeout: timeoutMs !== -1 ? timeoutMs : undefined,
173
+ headers,
174
+ hostname: urlObj.hostname,
175
+ method: "HEAD",
176
+ path: urlObj.pathname + urlObj.search,
177
+ port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
178
+ };
179
+
180
+ return new Promise((resolve, reject) => {
181
+ const req = p.request(reqOptions, (res) => {
182
+ res.destroy();
183
+ resolve(res.headers);
33
184
  });
34
- const timeout = determineTimeout(callbackOrOptions);
35
- if (timeout !== -1) {
36
- // https://stackoverflow.com/a/12783062
37
- request.on("socket", (socket) => {
38
- socket.setTimeout(timeout);
39
- socket.on("timeout", () => {
40
- request.abort();
185
+
186
+ req.on("error", reject);
187
+
188
+ if (timeoutMs !== -1) {
189
+ // https://stackoverflow.com/a/12783062
190
+ req.on("socket", (socket) => {
191
+ socket.setTimeout(timeoutMs);
192
+ socket.on("timeout", () => {
193
+ req.destroy(new Error("The request timed out."));
194
+ });
41
195
  });
42
- });
43
- }
196
+ }
197
+
198
+ req.end();
199
+ });
44
200
  }
45
201
 
46
- encodeURI() {
47
- return encodeURI(...arguments);
202
+ /**
203
+ * Convert the given URL content to a buffer.
204
+ */
205
+ static async urlToBuffer(params) {
206
+ const timeoutMs = determineTimeout(params);
207
+ const { logger, url, useGoogleBotUserAgent = false } = params || {};
208
+ const chunks = await this.urlToData({
209
+ logger,
210
+ timeoutMs,
211
+ url,
212
+ useGoogleBotUserAgent,
213
+ });
214
+ return Buffer.concat(chunks);
48
215
  }
49
216
 
50
- encodeURIComponent() {
51
- return encodeURIComponent(...arguments);
217
+ /**
218
+ * Get data from the given URL as chunks.
219
+ */
220
+ static async urlToData(params) {
221
+ const timeoutMs = determineTimeout(params);
222
+ const {
223
+ headers,
224
+ logger,
225
+ url,
226
+ useGoogleBotUserAgent = false,
227
+ } = params || {};
228
+
229
+ const readable = await this.urlToReadStream({
230
+ headers,
231
+ logger,
232
+ timeoutMs,
233
+ url,
234
+ useGoogleBotUserAgent,
235
+ });
236
+
237
+ const chunks = [];
238
+ for await (const chunk of readable) {
239
+ chunks.push(chunk);
240
+ }
241
+ return chunks;
52
242
  }
53
243
 
54
- // http://stackoverflow.com/questions/5922842/getting-http-headers-with-node-js
55
- // http://stackoverflow.com/a/6001507
56
- headers(url, callbackOrOptions, callback) {
57
- if (typeof arguments[arguments.length - 1] !== "function")
58
- return promises().callCallback(this, this.headers, ...arguments);
59
- const cb = arguments[arguments.length - 1];
60
- const parsedUrl = URL ? new URL(url) : urlUtil().parse(url);
61
- const port = parsedUrl.port
62
- ? parsedUrl.port
63
- : parsedUrl.protocol === "https:"
64
- ? 443
65
- : 80;
244
+ /**
245
+ * Download the given URL to the given local file.
246
+ */
247
+ static async urlToFile(params) {
248
+ const timeoutMs = determineTimeout(params);
249
+ const {
250
+ file,
251
+ logger,
252
+ maxRedirects,
253
+ url,
254
+ useGoogleBotUserAgent = false,
255
+ } = params || {};
66
256
 
67
- let options = Object.assign(
68
- {},
69
- callbackOrOptions
70
- ? typeof callbackOrOptions === "function"
71
- ? {}
72
- : callbackOrOptions
73
- : {},
74
- );
75
- options = Object.assign(options, {
76
- host: parsedUrl.host,
77
- method: "HEAD",
78
- path: parsedUrl.path,
79
- port,
257
+ const readable = await this.urlToReadStream({
258
+ logger,
259
+ maxRedirects,
260
+ timeoutMs,
261
+ url,
262
+ useGoogleBotUserAgent,
80
263
  });
81
- let cbCalled = false;
82
- const request = getProtocolObject(parsedUrl.protocol)
83
- .request(options, (res) => {
84
- res.destroy();
85
- if (!cbCalled) {
86
- cbCalled = true;
87
- cb(null, res.headers);
88
- }
89
- })
90
- .on("error", (error) => {
91
- if (!cbCalled) {
92
- cbCalled = true;
93
- cb(error);
264
+
265
+ const writable = Files.createWriteStream(file);
266
+ await pipeline(readable, writable);
267
+ }
268
+
269
+ /**
270
+ * Get the HTTP stream for the given URL with redirect and timeout support.
271
+ */
272
+ static async urlToReadStream(params) {
273
+ const timeoutMs = determineTimeout(params);
274
+ const {
275
+ headers: customHeaders = {},
276
+ logger,
277
+ maxRedirects = 0,
278
+ url,
279
+ useGoogleBotUserAgent = false,
280
+ } = params || {};
281
+
282
+ const urlObj = parseUrl(url);
283
+ const p = urlObj.protocol === "http:" ? http : https;
284
+ let headers = { ...customHeaders };
285
+
286
+ if (useGoogleBotUserAgent && !headers["User-Agent"]) {
287
+ headers["User-Agent"] = await getGoogleBotUserAgentHeader(logger);
288
+ }
289
+
290
+ return new Promise((resolve, reject) => {
291
+ const req = p.get(urlObj, { headers }, (res) => {
292
+ const { statusCode } = res;
293
+
294
+ if (statusCode !== 200) {
295
+ if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
296
+ if (maxRedirects > 0) {
297
+ const newUrl = new URL(res.headers.location, urlObj);
298
+ res.resume(); // Free memory.
299
+ Urls.urlToReadStream({
300
+ headers,
301
+ logger,
302
+ maxRedirects: maxRedirects - 1,
303
+ timeoutMs,
304
+ url: newUrl,
305
+ useGoogleBotUserAgent,
306
+ })
307
+ .then(resolve)
308
+ .catch(reject);
309
+ return;
310
+ }
311
+ }
312
+
313
+ res.resume();
314
+ const error = new Error(
315
+ `The request for ${urlObj.href} failed with status code ${statusCode}.`,
316
+ );
317
+ error.headers = res.headers;
318
+ error.statusCode = statusCode;
319
+ error.url = urlObj.href;
320
+ return reject(error);
94
321
  }
322
+
323
+ resolve(res);
95
324
  });
96
- const timeout = determineTimeout(callbackOrOptions);
97
- if (timeout !== -1) {
98
- // https://stackoverflow.com/a/12783062
99
- request.on("socket", (socket) => {
100
- socket.setTimeout(timeout);
101
- socket.on("timeout", () => {
102
- request.destroy(new Error("The request timed out."));
325
+
326
+ req.on("error", reject);
327
+
328
+ if (timeoutMs !== -1) {
329
+ // https://stackoverflow.com/a/12783062
330
+ req.on("socket", (socket) => {
331
+ socket.setTimeout(timeoutMs);
332
+ socket.on("timeout", () => {
333
+ req.destroy(new Error("The request timed out."));
334
+ });
103
335
  });
104
- });
105
- }
106
- request.end();
336
+ }
337
+ });
107
338
  }
108
- }
109
339
 
110
- module.exports = Urls;
340
+ /**
341
+ * Proxy the HTTP request for the given URL to the given web response.
342
+ */
343
+ static async urlToResponse(params) {
344
+ const timeoutMs = determineTimeout(params);
345
+ const {
346
+ logger,
347
+ response,
348
+ url,
349
+ useGoogleBotUserAgent = false,
350
+ } = params || {};
111
351
 
112
- function determineTimeout(params) {
113
- if (!params) return -1;
114
- const parsedInt = Number.parseInt(params.timeout);
115
- if (params.timeout != null && !Number.isNaN(parsedInt) && parsedInt >= 0)
116
- return parsedInt;
117
- return -1;
118
- }
352
+ const readable = await Urls.urlToReadStream({
353
+ logger,
354
+ timeoutMs,
355
+ url,
356
+ useGoogleBotUserAgent,
357
+ });
119
358
 
120
- const dependencies = {};
121
- function get(dependency) {
122
- return (
123
- dependencies[dependency] || (dependencies[dependency] = require(dependency))
124
- );
125
- }
359
+ response.set(readable.headers);
360
+ await pipeline(readable, response);
361
+ }
126
362
 
127
- function files() {
128
- return get("@util.js/node-files");
129
- }
130
- function http() {
131
- return get("http");
132
- }
133
- function https() {
134
- return get("https");
135
- }
136
- function promises() {
137
- return get("@util.js/promises");
138
- }
139
- function urlUtil() {
140
- return get("url");
141
- }
363
+ /**
364
+ * Convert the given URL content to a string, auto-detecting its charset from Content-Type headers.
365
+ */
366
+ static async urlToString(params) {
367
+ const timeoutMs = determineTimeout(params);
368
+ const {
369
+ encoding: explicitEncoding,
370
+ headers,
371
+ logger,
372
+ url,
373
+ useGoogleBotUserAgent = false,
374
+ } = params || {};
142
375
 
143
- function getProtocolObject(protocolStr) {
144
- switch (protocolStr) {
145
- case "https:":
146
- return https();
147
- case "http:":
148
- return http();
149
- default:
150
- throw new TypeError(
151
- 'The protocol must be "https:" or "http:", not "' + protocolStr + '"',
152
- );
376
+ const readable = await this.urlToReadStream({
377
+ headers,
378
+ logger,
379
+ timeoutMs,
380
+ url,
381
+ useGoogleBotUserAgent,
382
+ });
383
+
384
+ let encoding = explicitEncoding;
385
+
386
+ if (!encoding) {
387
+ const contentType = readable.headers?.["content-type"];
388
+ if (contentType) {
389
+ const match = contentType.match(/charset=([^;]+)/i);
390
+ if (match) {
391
+ encoding = match[1].trim().replace(/['"]/g, "").toLowerCase();
392
+ }
393
+ }
394
+ }
395
+
396
+ // Map common charset names to Node.js Buffer encodings.
397
+ if (encoding === "iso-8859-1") {
398
+ encoding = "latin1";
399
+ } else if (encoding === "utf-8") {
400
+ encoding = "utf8";
401
+ }
402
+
403
+ const chunks = [];
404
+ for await (const chunk of readable) {
405
+ chunks.push(chunk);
406
+ }
407
+
408
+ const buffer = Buffer.concat(chunks);
409
+
410
+ try {
411
+ return buffer.toString(encoding || "utf8");
412
+ } catch (err) {
413
+ // Fall back to utf8 if Node.js does not recognize the encoding.
414
+ return buffer.toString("utf8");
415
+ }
153
416
  }
154
417
  }
418
+
419
+ export default Urls;
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "author": "Chris Topher <c@creemama.com>",
3
3
  "dependencies": {
4
- "@util.js/node-files": "0.41.8",
5
- "@util.js/promises": "0.41.8"
4
+ "@util.js/node-files": "0.42.1",
5
+ "@util.js/promises": "0.42.1"
6
6
  },
7
7
  "description": "JavaScript utility methods for URLs",
8
+ "exports": {
9
+ ".": "./index.js"
10
+ },
8
11
  "files": [
9
12
  "index.js",
10
13
  "lib"
@@ -19,6 +22,7 @@
19
22
  "utiljs"
20
23
  ],
21
24
  "license": "MIT",
25
+ "main": "./index.js",
22
26
  "name": "@util.js/node-http",
23
27
  "publishConfig": {
24
28
  "access": "public"
@@ -31,5 +35,6 @@
31
35
  "type": "git",
32
36
  "url": "https://github.com/creemama/utiljs/tree/master/packages/utiljs-node-http"
33
37
  },
34
- "version": "0.41.8"
38
+ "type": "module",
39
+ "version": "0.42.1"
35
40
  }