rekwest 3.3.4 → 4.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/src/index.mjs CHANGED
@@ -3,6 +3,10 @@ import http2 from 'node:http2';
3
3
  import https from 'node:https';
4
4
  import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
5
5
  import { ackn } from './ackn.mjs';
6
+ import {
7
+ redirectModes,
8
+ redirectStatusCodes,
9
+ } from './constants.mjs';
6
10
  import { Cookies } from './cookies.mjs';
7
11
  import { RequestError } from './errors.mjs';
8
12
  import { APPLICATION_OCTET_STREAM } from './mediatypes.mjs';
@@ -13,14 +17,15 @@ import {
13
17
  merge,
14
18
  mixin,
15
19
  preflight,
16
- redirects,
17
- revise,
20
+ sameOrigin,
21
+ sanitize,
18
22
  transform,
19
23
  } from './utils.mjs';
20
24
 
21
25
  export { constants } from 'node:http2';
22
26
 
23
27
  export * from './ackn.mjs';
28
+ export * from './constants.mjs';
24
29
  export * from './cookies.mjs';
25
30
  export * from './errors.mjs';
26
31
  export * from './file.mjs';
@@ -29,14 +34,16 @@ export * as mediatypes from './mediatypes.mjs';
29
34
  export * from './utils.mjs';
30
35
 
31
36
  const {
32
- HTTP2_HEADER_CONTENT_LENGTH,
37
+ HTTP2_HEADER_AUTHORIZATION,
33
38
  HTTP2_HEADER_CONTENT_TYPE,
34
39
  HTTP2_HEADER_LOCATION,
35
40
  HTTP2_HEADER_RETRY_AFTER,
36
41
  HTTP2_HEADER_SET_COOKIE,
37
42
  HTTP2_METHOD_GET,
38
43
  HTTP2_METHOD_HEAD,
44
+ HTTP2_METHOD_POST,
39
45
  HTTP_STATUS_BAD_REQUEST,
46
+ HTTP_STATUS_FOUND,
40
47
  HTTP_STATUS_MOVED_PERMANENTLY,
41
48
  HTTP_STATUS_SEE_OTHER,
42
49
  HTTP_STATUS_SERVICE_UNAVAILABLE,
@@ -70,8 +77,10 @@ let defaults = {
70
77
  timeout: 3e5,
71
78
  };
72
79
 
73
- export default async function rekwest(url, options = {}) {
74
- ({ url } = revise({ options, url }));
80
+ export default async function rekwest(...args) {
81
+ let options = sanitize(...args);
82
+ const { url } = options;
83
+
75
84
  if (!options.redirected) {
76
85
  options = merge(rekwest.defaults, options);
77
86
  }
@@ -98,17 +107,14 @@ export default async function rekwest(url, options = {}) {
98
107
  ].forEach((it) => Reflect.deleteProperty(options, it));
99
108
  }
100
109
 
101
- options = preflight(options);
110
+ options = await transform(preflight(options));
102
111
 
103
112
  const { cookies, digest, follow, h2, redirect, redirected, thenable } = options;
104
113
  const { request } = (url.protocol === 'http:' ? http : https);
105
- let { body } = options;
106
114
 
107
115
  const promise = new Promise((resolve, reject) => {
108
116
  let client, req;
109
117
 
110
- body &&= transform(body, options);
111
-
112
118
  if (h2) {
113
119
  client = http2.connect(url.origin, options);
114
120
  req = client.request(options.headers, options);
@@ -149,30 +155,53 @@ export default async function rekwest(url, options = {}) {
149
155
  : void 0,
150
156
  });
151
157
 
152
- if (follow && /^3\d{2}$/.test(res.statusCode) && res.headers[HTTP2_HEADER_LOCATION]) {
153
- if (redirect === redirects.error) {
158
+ const { statusCode } = res;
159
+
160
+ if (follow && /3\d{2}/.test(statusCode) && res.headers[HTTP2_HEADER_LOCATION]) {
161
+ if (!redirectStatusCodes.includes(statusCode)) {
162
+ return res.emit('error', new RangeError(`Invalid status code: ${ statusCode }`));
163
+ }
164
+
165
+ if (redirect === redirectModes.error) {
154
166
  return res.emit('error', new RequestError(`Unexpected redirect, redirect mode is set to '${ redirect }'.`));
155
167
  }
156
168
 
157
- if (redirect === redirects.follow) {
158
- options.url = new URL(res.headers[HTTP2_HEADER_LOCATION], url).href;
169
+ if (redirect === redirectModes.follow) {
170
+ const location = new URL(res.headers[HTTP2_HEADER_LOCATION], url);
159
171
 
160
- if (res.statusCode !== HTTP_STATUS_SEE_OTHER
161
- && body === Object(body) && body.pipe?.constructor === Function) {
172
+ if (!/^https?:/.test(location.protocol)) {
173
+ return res.emit('error', new RequestError('URL scheme must be "http" or "https".'));
174
+ }
175
+
176
+ if (!sameOrigin(location, url)) {
177
+ Reflect.deleteProperty(options.headers, HTTP2_HEADER_AUTHORIZATION);
178
+ location.password = location.username = '';
179
+ }
180
+
181
+ options.url = location;
182
+
183
+ if (statusCode !== HTTP_STATUS_SEE_OTHER && options?.body?.pipe?.constructor === Function) {
162
184
  return res.emit('error', new RequestError(`Unable to ${ redirect } redirect with streamable body.`));
163
185
  }
164
186
 
165
187
  options.follow--;
166
188
 
167
- if (res.statusCode === HTTP_STATUS_SEE_OTHER) {
168
- Reflect.deleteProperty(options.headers, HTTP2_HEADER_CONTENT_LENGTH);
169
- options.method = HTTP2_METHOD_GET;
189
+ if (([
190
+ HTTP_STATUS_MOVED_PERMANENTLY,
191
+ HTTP_STATUS_FOUND,
192
+ ].includes(statusCode) && request.method === HTTP2_METHOD_POST) || (statusCode === HTTP_STATUS_SEE_OTHER && ![
193
+ HTTP2_METHOD_GET,
194
+ HTTP2_METHOD_HEAD,
195
+ ].includes(options.method))) {
196
+ Object.keys(options.headers).filter((it) => /^content-/i.test(it))
197
+ .forEach((it) => Reflect.deleteProperty(options.headers, it));
170
198
  options.body = null;
199
+ options.method = HTTP2_METHOD_GET;
171
200
  }
172
201
 
173
202
  Reflect.set(options, 'redirected', true);
174
203
 
175
- if (res.statusCode === HTTP_STATUS_MOVED_PERMANENTLY && res.headers[HTTP2_HEADER_RETRY_AFTER]) {
204
+ if (statusCode === HTTP_STATUS_MOVED_PERMANENTLY && res.headers[HTTP2_HEADER_RETRY_AFTER]) {
176
205
  let interval = res.headers[HTTP2_HEADER_RETRY_AFTER];
177
206
 
178
207
  interval = Number(interval) * 1000 || new Date(interval) - Date.now();
@@ -188,14 +217,14 @@ export default async function rekwest(url, options = {}) {
188
217
  }
189
218
  }
190
219
 
191
- if (res.statusCode >= HTTP_STATUS_BAD_REQUEST) {
220
+ if (statusCode >= HTTP_STATUS_BAD_REQUEST) {
192
221
  return reject(mixin(res, options));
193
222
  }
194
223
 
195
224
  resolve(mixin(res, options));
196
225
  });
197
226
 
198
- dispatch(req, { ...options, body });
227
+ dispatch(options, req);
199
228
  });
200
229
 
201
230
  try {
@@ -242,16 +271,15 @@ export default async function rekwest(url, options = {}) {
242
271
 
243
272
  Reflect.defineProperty(rekwest, 'stream', {
244
273
  enumerable: true,
245
- value(url, options = {}) {
246
- ({ url } = revise({ options, url }));
247
- options = preflight({
274
+ value(...args) {
275
+ const options = preflight({
248
276
  ...merge(rekwest.defaults, {
249
277
  headers: { [HTTP2_HEADER_CONTENT_TYPE]: APPLICATION_OCTET_STREAM },
250
- }, options),
251
- redirect: redirects.manual,
278
+ }, sanitize(...args)),
279
+ redirect: redirectModes.manual,
252
280
  });
253
281
 
254
- const { h2 } = options;
282
+ const { h2, url } = options;
255
283
  const { request } = (url.protocol === 'http:' ? http : https);
256
284
  let client, req;
257
285
 
package/src/utils.mjs CHANGED
@@ -1,14 +1,13 @@
1
1
  import { Blob } from 'node:buffer';
2
2
  import http2 from 'node:http2';
3
3
  import {
4
- PassThrough,
4
+ pipeline,
5
5
  Readable,
6
6
  } from 'node:stream';
7
- import {
8
- promisify,
9
- types,
10
- } from 'node:util';
7
+ import { buffer } from 'node:stream/consumers';
8
+ import { types } from 'node:util';
11
9
  import zlib from 'node:zlib';
10
+ import { redirectModes } from './constants.mjs';
12
11
  import { Cookies } from './cookies.mjs';
13
12
  import { TimeoutError } from './errors.mjs';
14
13
  import { File } from './file.mjs';
@@ -37,12 +36,7 @@ const {
37
36
  HTTP2_METHOD_HEAD,
38
37
  } = http2.constants;
39
38
 
40
- const brotliCompress = promisify(zlib.brotliCompress);
41
- const brotliDecompress = promisify(zlib.brotliDecompress);
42
- const gzip = promisify(zlib.gzip);
43
- const gunzip = promisify(zlib.gunzip);
44
- const deflate = promisify(zlib.deflate);
45
- const inflate = promisify(zlib.inflate);
39
+ const unwind = (encodings) => encodings.split(',').map((it) => it.trim());
46
40
 
47
41
  export const admix = (res, headers, options) => {
48
42
  const { h2 } = options;
@@ -86,48 +80,59 @@ export const affix = (client, req, options) => {
86
80
  });
87
81
  };
88
82
 
89
- export const collate = (entity, primordial) => {
90
- if (entity?.constructor !== primordial) {
83
+ export const brandCheck = (value, ctor) => {
84
+ if (!(value instanceof ctor)) {
91
85
  throw new TypeError('Illegal invocation');
92
86
  }
93
87
  };
94
88
 
95
- export const compress = (buf, encoding, { async = false } = {}) => {
96
- encoding &&= encoding.match(/(?<encoding>\bbr\b|\bdeflate\b|\bgzip\b)/i)?.groups.encoding.toLowerCase();
97
- const compressor = {
98
- br: async ? brotliCompress : zlib.brotliCompressSync,
99
- deflate: async ? deflate : zlib.deflateSync,
100
- gzip: async ? gzip : zlib.gzipSync,
101
- }[encoding];
102
-
103
- return compressor?.(buf) ?? (async ? Promise.resolve(buf) : buf);
104
- };
105
-
106
- export const decompress = (buf, encoding, { async = false } = {}) => {
107
- encoding &&= encoding.match(/(?<encoding>\bbr\b|\bdeflate\b|\bgzip\b)/i)?.groups.encoding.toLowerCase();
108
- const decompressor = {
109
- br: async ? brotliDecompress : zlib.brotliDecompressSync,
110
- deflate: async ? inflate : zlib.inflateSync,
111
- gzip: async ? gunzip : zlib.gunzipSync,
112
- }[encoding];
89
+ export const compress = (readable, encodings = '') => {
90
+ const encoders = [];
91
+
92
+ encodings = unwind(encodings);
93
+
94
+ for (const encoding of encodings) {
95
+ if (/\bbr\b/i.test(encoding)) {
96
+ encoders.push(zlib.createBrotliCompress());
97
+ } else if (/\bdeflate(?!-(?:\w+)?)\b/i.test(encoding)) {
98
+ encoders.push(zlib.createDeflate());
99
+ } else if (/\bdeflate-raw\b/i.test(encoding)) {
100
+ encoders.push(zlib.createDeflateRaw());
101
+ } else if (/\bgzip\b/i.test(encoding)) {
102
+ encoders.push(zlib.createGzip());
103
+ } else {
104
+ return readable;
105
+ }
106
+ }
113
107
 
114
- return decompressor?.(buf) ?? (async ? Promise.resolve(buf) : buf);
108
+ return pipeline(readable, ...encoders, () => void 0);
115
109
  };
116
110
 
117
- export const dispatch = (req, { body, headers }) => {
118
- if (body === Object(body) && !Buffer.isBuffer(body)) {
119
- if (body.pipe?.constructor !== Function
120
- && (Reflect.has(body, Symbol.asyncIterator) || Reflect.has(body, Symbol.iterator))) {
121
- body = Readable.from(body);
111
+ export const decompress = (readable, encodings = '') => {
112
+ const decoders = [];
113
+
114
+ encodings = unwind(encodings);
115
+
116
+ for (const encoding of encodings) {
117
+ if (/\bbr\b/i.test(encoding)) {
118
+ decoders.push(zlib.createBrotliDecompress());
119
+ } else if (/\bdeflate(?!-(?:\w+)?)\b/i.test(encoding)) {
120
+ decoders.push(zlib.createInflate());
121
+ } else if (/\bdeflate-raw\b/i.test(encoding)) {
122
+ decoders.push(zlib.createInflateRaw());
123
+ } else if (/\bgzip\b/i.test(encoding)) {
124
+ decoders.push(zlib.createGunzip());
125
+ } else {
126
+ return readable;
122
127
  }
128
+ }
123
129
 
124
- const compressor = {
125
- br: zlib.createBrotliCompress,
126
- deflate: zlib.createDeflate,
127
- gzip: zlib.createGzip,
128
- }[headers[HTTP2_HEADER_CONTENT_ENCODING]] ?? PassThrough;
130
+ return pipeline(readable, ...decoders, () => void 0);
131
+ };
129
132
 
130
- body.pipe(compressor()).pipe(req);
133
+ export const dispatch = ({ body }, req) => {
134
+ if (body?.pipe?.constructor === Function) {
135
+ body.pipe(req);
131
136
  } else {
132
137
  req.end(body);
133
138
  }
@@ -170,7 +175,7 @@ export const mixin = (res, { digest = false, parse = false } = {}) => {
170
175
  arrayBuffer: {
171
176
  enumerable: true,
172
177
  value: async function () {
173
- collate(this, res?.constructor);
178
+ brandCheck(this, res?.constructor);
174
179
  parse &&= false;
175
180
  const { buffer, byteLength, byteOffset } = await this.body();
176
181
 
@@ -180,7 +185,7 @@ export const mixin = (res, { digest = false, parse = false } = {}) => {
180
185
  blob: {
181
186
  enumerable: true,
182
187
  value: async function () {
183
- collate(this, res?.constructor);
188
+ brandCheck(this, res?.constructor);
184
189
  const val = await this.arrayBuffer();
185
190
 
186
191
  return new Blob([val]);
@@ -189,7 +194,7 @@ export const mixin = (res, { digest = false, parse = false } = {}) => {
189
194
  json: {
190
195
  enumerable: true,
191
196
  value: async function () {
192
- collate(this, res?.constructor);
197
+ brandCheck(this, res?.constructor);
193
198
  const val = await this.text();
194
199
 
195
200
  return JSON.parse(val);
@@ -198,7 +203,7 @@ export const mixin = (res, { digest = false, parse = false } = {}) => {
198
203
  text: {
199
204
  enumerable: true,
200
205
  value: async function () {
201
- collate(this, res?.constructor);
206
+ brandCheck(this, res?.constructor);
202
207
  const blob = await this.blob();
203
208
 
204
209
  return blob.text();
@@ -211,25 +216,25 @@ export const mixin = (res, { digest = false, parse = false } = {}) => {
211
216
  body: {
212
217
  enumerable: true,
213
218
  value: async function () {
214
- collate(this, res?.constructor);
219
+ brandCheck(this, res?.constructor);
215
220
 
216
221
  if (this.bodyUsed) {
217
222
  throw new TypeError('Response stream already read');
218
223
  }
219
224
 
220
- let spool = [];
225
+ let body = [];
221
226
 
222
- for await (const chunk of this) {
223
- spool.push(chunk);
227
+ for await (const chunk of decompress(this, this.headers[HTTP2_HEADER_CONTENT_ENCODING])) {
228
+ body.push(chunk);
224
229
  }
225
230
 
226
- spool = Buffer.concat(spool);
231
+ body = Buffer.concat(body);
227
232
 
228
- if (spool.length) {
229
- spool = await decompress(spool, this.headers[HTTP2_HEADER_CONTENT_ENCODING], { async: true });
233
+ if (!body.length && parse) {
234
+ return null;
230
235
  }
231
236
 
232
- if (spool.length && parse) {
237
+ if (body.length && parse) {
233
238
  const contentType = this.headers[HTTP2_HEADER_CONTENT_TYPE] ?? '';
234
239
  const charset = contentType.split(';')
235
240
  .find((it) => /charset=/i.test(it))
@@ -239,17 +244,17 @@ export const mixin = (res, { digest = false, parse = false } = {}) => {
239
244
  .trim() || 'utf-8';
240
245
 
241
246
  if (/\bjson\b/i.test(contentType)) {
242
- spool = JSON.parse(spool.toString(charset));
243
- } else if (/\b(text|xml)\b/i.test(contentType)) {
244
- if (/\b(latin1|ucs-2|utf-(8|16le))\b/.test(charset)) {
245
- spool = spool.toString(charset);
247
+ body = JSON.parse(body.toString(charset));
248
+ } else if (/\b(?:text|xml)\b/i.test(contentType)) {
249
+ if (/\b(?:latin1|ucs-2|utf-(?:8|16le))\b/.test(charset)) {
250
+ body = body.toString(charset);
246
251
  } else {
247
- spool = new TextDecoder(charset).decode(spool);
252
+ body = new TextDecoder(charset).decode(body);
248
253
  }
249
254
  }
250
255
  }
251
256
 
252
- return spool;
257
+ return body;
253
258
  },
254
259
  writable: true,
255
260
  },
@@ -297,7 +302,7 @@ export const preflight = (options) => {
297
302
  options.h2 ??= h2;
298
303
  options.headers = {
299
304
  [HTTP2_HEADER_ACCEPT]: `${ APPLICATION_JSON }, ${ TEXT_PLAIN }, ${ WILDCARD }`,
300
- [HTTP2_HEADER_ACCEPT_ENCODING]: 'br, deflate, gzip, identity',
305
+ [HTTP2_HEADER_ACCEPT_ENCODING]: 'br, deflate, deflate-raw, gzip, identity',
301
306
  ...Object.entries(options.headers ?? {})
302
307
  .reduce((acc, [key, val]) => (acc[key.toLowerCase()] = val, acc), {}),
303
308
  ...h2 && {
@@ -310,9 +315,9 @@ export const preflight = (options) => {
310
315
 
311
316
  options.method ??= method;
312
317
  options.parse ??= true;
313
- options.redirect ??= redirects.follow;
318
+ options.redirect ??= redirectModes.follow;
314
319
 
315
- if (!Object.values(redirects).includes(options.redirect)) {
320
+ if (!Reflect.has(redirectModes, options.redirect)) {
316
321
  options.createConnection?.().destroy();
317
322
  throw new TypeError(`Failed to read the 'redirect' property from 'options': The provided value '${
318
323
  options.redirect
@@ -325,13 +330,7 @@ export const preflight = (options) => {
325
330
  return options;
326
331
  };
327
332
 
328
- export const redirects = {
329
- error: 'error',
330
- follow: 'follow',
331
- manual: 'manual',
332
- };
333
-
334
- export const revise = ({ url, options }) => {
333
+ export const sanitize = (url, options = {}) => {
335
334
  if (options.trimTrailingSlashes) {
336
335
  url = `${ url }`.replace(/(?<!:)\/+/gi, '/');
337
336
  }
@@ -341,6 +340,8 @@ export const revise = ({ url, options }) => {
341
340
  return Object.assign(options, { url });
342
341
  };
343
342
 
343
+ export const sameOrigin = (a, b) => a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
344
+
344
345
  export async function* tap(value) {
345
346
  if (Reflect.has(value, Symbol.asyncIterator)) {
346
347
  yield* value;
@@ -351,13 +352,11 @@ export async function* tap(value) {
351
352
  }
352
353
  }
353
354
 
354
- export const transform = (body, options) => {
355
- let headers = {};
355
+ export const transform = async (options) => {
356
+ let { body, headers } = options;
356
357
 
357
- if (types.isAnyArrayBuffer(body) && !Buffer.isBuffer(body)) {
358
- body = Buffer.from(body);
359
- } else if (types.isArrayBufferView(body) && !Buffer.isBuffer(body)) {
360
- body = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
358
+ if (!body) {
359
+ return options;
361
360
  }
362
361
 
363
362
  if (File.alike(body)) {
@@ -365,38 +364,47 @@ export const transform = (body, options) => {
365
364
  [HTTP2_HEADER_CONTENT_LENGTH]: body.size,
366
365
  [HTTP2_HEADER_CONTENT_TYPE]: body.type || APPLICATION_OCTET_STREAM,
367
366
  };
368
- body = body.stream?.() ?? Readable.from(tap(body));
367
+ body = body.stream();
369
368
  } else if (FormData.alike(body)) {
370
369
  body = FormData.actuate(body);
371
370
  headers = { [HTTP2_HEADER_CONTENT_TYPE]: body.contentType };
372
- } else if (body === Object(body) && !Reflect.has(body, Symbol.asyncIterator)) {
373
- if (body.constructor === URLSearchParams) {
374
- headers = { [HTTP2_HEADER_CONTENT_TYPE]: APPLICATION_FORM_URLENCODED };
375
- body = body.toString();
376
- } else if (!Buffer.isBuffer(body)
377
- && !(!Array.isArray(body) && Reflect.has(body, Symbol.iterator))) {
378
- headers = { [HTTP2_HEADER_CONTENT_TYPE]: APPLICATION_JSON };
379
- body = JSON.stringify(body);
371
+ } else if (!Buffer.isBuffer(body)) {
372
+ if (types.isAnyArrayBuffer(body)) {
373
+ body = Buffer.from(body);
374
+ } else if (types.isArrayBufferView(body)) {
375
+ body = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
376
+ } else if (body === Object(body) && !Reflect.has(body, Symbol.asyncIterator)) {
377
+ if (body.constructor === URLSearchParams) {
378
+ headers = { [HTTP2_HEADER_CONTENT_TYPE]: APPLICATION_FORM_URLENCODED };
379
+ body = body.toString();
380
+ } else if (!(!Array.isArray(body) && Reflect.has(body, Symbol.iterator))) {
381
+ headers = { [HTTP2_HEADER_CONTENT_TYPE]: APPLICATION_JSON };
382
+ body = JSON.stringify(body);
383
+ }
380
384
  }
385
+ }
381
386
 
382
- if (Buffer.isBuffer(body) || body !== Object(body)) {
383
- if (options.headers[HTTP2_HEADER_CONTENT_ENCODING]) {
384
- body = compress(body, options.headers[HTTP2_HEADER_CONTENT_ENCODING]);
385
- }
387
+ const encodings = options.headers[HTTP2_HEADER_CONTENT_ENCODING];
386
388
 
387
- headers = {
388
- ...headers,
389
- [HTTP2_HEADER_CONTENT_LENGTH]: Buffer.byteLength(body),
390
- };
391
- }
389
+ if (body === Object(body)
390
+ && (Reflect.has(body, Symbol.asyncIterator) || (!Array.isArray(body) && Reflect.has(body, Symbol.iterator)))) {
391
+ body = encodings ? compress(Readable.from(body), encodings) : Readable.from(body);
392
+ } else if (encodings) {
393
+ body = await buffer(compress(Readable.from(body), encodings));
392
394
  }
393
395
 
394
396
  Object.assign(options.headers, {
395
397
  ...headers,
398
+ ...!body[Symbol.asyncIterator] && {
399
+ [HTTP2_HEADER_CONTENT_LENGTH]: Buffer.byteLength(body),
400
+ },
396
401
  ...options.headers[HTTP2_HEADER_CONTENT_TYPE] && {
397
402
  [HTTP2_HEADER_CONTENT_TYPE]: options.headers[HTTP2_HEADER_CONTENT_TYPE],
398
403
  },
399
404
  });
400
405
 
401
- return body;
406
+ return {
407
+ ...options,
408
+ body,
409
+ };
402
410
  };