codekingpro/portable-devtools
114k
1'use strict'
2
3const assert = require('node:assert')
4const { kDestroyed, kBodyUsed, kListeners, kBody } = require('./symbols')
5const { IncomingMessage } = require('node:http')
6const stream = require('node:stream')
7const net = require('node:net')
8const { Blob } = require('node:buffer')
9const nodeUtil = require('node:util')
10const { stringify } = require('node:querystring')
11const { EventEmitter: EE } = require('node:events')
12const { InvalidArgumentError } = require('./errors')
13const { headerNameLowerCasedRecord } = require('./constants')
14const { tree } = require('./tree')
15
16const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v))
17
18class BodyAsyncIterable {
19 constructor (body) {
20 this[kBody] = body
21 this[kBodyUsed] = false
22 }
23
24 async * [Symbol.asyncIterator] () {
25 assert(!this[kBodyUsed], 'disturbed')
26 this[kBodyUsed] = true
27 yield * this[kBody]
28 }
29}
30
31function wrapRequestBody (body) {
32 if (isStream(body)) {
33 // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp
34 // so that it can be dispatched again?
35 // TODO (fix): Do we need 100-expect support to provide a way to do this properly?
36 if (bodyLength(body) === 0) {
37 body
38 .on('data', function () {
39 assert(false)
40 })
41 }
42
43 if (typeof body.readableDidRead !== 'boolean') {
44 body[kBodyUsed] = false
45 EE.prototype.on.call(body, 'data', function () {
46 this[kBodyUsed] = true
47 })
48 }
49
50 return body
51 } else if (body && typeof body.pipeTo === 'function') {
52 // TODO (fix): We can't access ReadableStream internal state
53 // to determine whether or not it has been disturbed. This is just
54 // a workaround.
55 return new BodyAsyncIterable(body)
56 } else if (
57 body &&
58 typeof body !== 'string' &&
59 !ArrayBuffer.isView(body) &&
60 isIterable(body)
61 ) {
62 // TODO: Should we allow re-using iterable if !this.opts.idempotent
63 // or through some other flag?
64 return new BodyAsyncIterable(body)
65 } else {
66 return body
67 }
68}
69
70function nop () {}
71
72function isStream (obj) {
73 return obj && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function'
74}
75
76// based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License)
77function isBlobLike (object) {
78 if (object === null) {
79 return false
80 } else if (object instanceof Blob) {
81 return true
82 } else if (typeof object !== 'object') {
83 return false
84 } else {
85 const sTag = object[Symbol.toStringTag]
86
87 return (sTag === 'Blob' || sTag === 'File') && (
88 ('stream' in object && typeof object.stream === 'function') ||
89 ('arrayBuffer' in object && typeof object.arrayBuffer === 'function')
90 )
91 }
92}
93
94function buildURL (url, queryParams) {
95 if (url.includes('?') || url.includes('#')) {
96 throw new Error('Query params cannot be passed when url already contains "?" or "#".')
97 }
98
99 const stringified = stringify(queryParams)
100
101 if (stringified) {
102 url += '?' + stringified
103 }
104
105 return url
106}
107
108function isValidPort (port) {
109 const value = parseInt(port, 10)
110 return (
111 value === Number(port) &&
112 value >= 0 &&
113 value <= 65535
114 )
115}
116
117function isHttpOrHttpsPrefixed (value) {
118 return (
119 value != null &&
120 value[0] === 'h' &&
121 value[1] === 't' &&
122 value[2] === 't' &&
123 value[3] === 'p' &&
124 (
125 value[4] === ':' ||
126 (
127 value[4] === 's' &&
128 value[5] === ':'
129 )
130 )
131 )
132}
133
134function parseURL (url) {
135 if (typeof url === 'string') {
136 url = new URL(url)
137
138 if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
139 throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.')
140 }
141
142 return url
143 }
144
145 if (!url || typeof url !== 'object') {
146 throw new InvalidArgumentError('Invalid URL: The URL argument must be a non-null object.')
147 }
148
149 if (!(url instanceof URL)) {
150 if (url.port != null && url.port !== '' && isValidPort(url.port) === false) {
151 throw new InvalidArgumentError('Invalid URL: port must be a valid integer or a string representation of an integer.')
152 }
153
154 if (url.path != null && typeof url.path !== 'string') {
155 throw new InvalidArgumentError('Invalid URL path: the path must be a string or null/undefined.')
156 }
157
158 if (url.pathname != null && typeof url.pathname !== 'string') {
159 throw new InvalidArgumentError('Invalid URL pathname: the pathname must be a string or null/undefined.')
160 }
161
162 if (url.hostname != null && typeof url.hostname !== 'string') {
163 throw new InvalidArgumentError('Invalid URL hostname: the hostname must be a string or null/undefined.')
164 }
165
166 if (url.origin != null && typeof url.origin !== 'string') {
167 throw new InvalidArgumentError('Invalid URL origin: the origin must be a string or null/undefined.')
168 }
169
170 if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
171 throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.')
172 }
173
174 const port = url.port != null
175 ? url.port
176 : (url.protocol === 'https:' ? 443 : 80)
177 let origin = url.origin != null
178 ? url.origin
179 : `${url.protocol || ''}//${url.hostname || ''}:${port}`
180 let path = url.path != null
181 ? url.path
182 : `${url.pathname || ''}${url.search || ''}`
183
184 if (origin[origin.length - 1] === '/') {
185 origin = origin.slice(0, origin.length - 1)
186 }
187
188 if (path && path[0] !== '/') {
189 path = `/${path}`
190 }
191 // new URL(path, origin) is unsafe when `path` contains an absolute URL
192 // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
193 // If first parameter is a relative URL, second param is required, and will be used as the base URL.
194 // If first parameter is an absolute URL, a given second param will be ignored.
195 return new URL(`${origin}${path}`)
196 }
197
198 if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
199 throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.')
200 }
201
202 return url
203}
204
205function parseOrigin (url) {
206 url = parseURL(url)
207
208 if (url.pathname !== '/' || url.search || url.hash) {
209 throw new InvalidArgumentError('invalid url')
210 }
211
212 return url
213}
214
215function getHostname (host) {
216 if (host[0] === '[') {
217 const idx = host.indexOf(']')
218
219 assert(idx !== -1)
220 return host.substring(1, idx)
221 }
222
223 const idx = host.indexOf(':')
224 if (idx === -1) return host
225
226 return host.substring(0, idx)
227}
228
229// IP addresses are not valid server names per RFC6066
230// > Currently, the only server names supported are DNS hostnames
231function getServerName (host) {
232 if (!host) {
233 return null
234 }
235
236 assert(typeof host === 'string')
237
238 const servername = getHostname(host)
239 if (net.isIP(servername)) {
240 return ''
241 }
242
243 return servername
244}
245
246function deepClone (obj) {
247 return JSON.parse(JSON.stringify(obj))
248}
249
250function isAsyncIterable (obj) {
251 return !!(obj != null && typeof obj[Symbol.asyncIterator] === 'function')
252}
253
254function isIterable (obj) {
255 return !!(obj != null && (typeof obj[Symbol.iterator] === 'function' || typeof obj[Symbol.asyncIterator] === 'function'))
256}
257
258function bodyLength (body) {
259 if (body == null) {
260 return 0
261 } else if (isStream(body)) {
262 const state = body._readableState
263 return state && state.objectMode === false && state.ended === true && Number.isFinite(state.length)
264 ? state.length
265 : null
266 } else if (isBlobLike(body)) {
267 return body.size != null ? body.size : null
268 } else if (isBuffer(body)) {
269 return body.byteLength
270 }
271
272 return null
273}
274
275function isDestroyed (body) {
276 return body && !!(body.destroyed || body[kDestroyed] || (stream.isDestroyed?.(body)))
277}
278
279function destroy (stream, err) {
280 if (stream == null || !isStream(stream) || isDestroyed(stream)) {
281 return
282 }
283
284 if (typeof stream.destroy === 'function') {
285 if (Object.getPrototypeOf(stream).constructor === IncomingMessage) {
286 // See: https://github.com/nodejs/node/pull/38505/files
287 stream.socket = null
288 }
289
290 stream.destroy(err)
291 } else if (err) {
292 queueMicrotask(() => {
293 stream.emit('error', err)
294 })
295 }
296
297 if (stream.destroyed !== true) {
298 stream[kDestroyed] = true
299 }
300}
301
302const KEEPALIVE_TIMEOUT_EXPR = /timeout=(\d+)/
303function parseKeepAliveTimeout (val) {
304 const m = val.toString().match(KEEPALIVE_TIMEOUT_EXPR)
305 return m ? parseInt(m[1], 10) * 1000 : null
306}
307
308/**
309 * Retrieves a header name and returns its lowercase value.
310 * @param {string | Buffer} value Header name
311 * @returns {string}
312 */
313function headerNameToString (value) {
314 return typeof value === 'string'
315 ? headerNameLowerCasedRecord[value] ?? value.toLowerCase()
316 : tree.lookup(value) ?? value.toString('latin1').toLowerCase()
317}
318
319/**
320 * Receive the buffer as a string and return its lowercase value.
321 * @param {Buffer} value Header name
322 * @returns {string}
323 */
324function bufferToLowerCasedHeaderName (value) {
325 return tree.lookup(value) ?? value.toString('latin1').toLowerCase()
326}
327
328/**
329 * @param {Record<string, string | string[]> | (Buffer | string | (Buffer | string)[])[]} headers
330 * @param {Record<string, string | string[]>} [obj]
331 * @returns {Record<string, string | string[]>}
332 */
333function parseHeaders (headers, obj) {
334 if (obj === undefined) obj = {}
335 for (let i = 0; i < headers.length; i += 2) {
336 const key = headerNameToString(headers[i])
337 let val = obj[key]
338
339 if (val) {
340 if (typeof val === 'string') {
341 val = [val]
342 obj[key] = val
343 }
344 val.push(headers[i + 1].toString('utf8'))
345 } else {
346 const headersValue = headers[i + 1]
347 if (typeof headersValue === 'string') {
348 obj[key] = headersValue
349 } else {
350 obj[key] = Array.isArray(headersValue) ? headersValue.map(x => x.toString('utf8')) : headersValue.toString('utf8')
351 }
352 }
353 }
354
355 // See https://github.com/nodejs/node/pull/46528
356 if ('content-length' in obj && 'content-disposition' in obj) {
357 obj['content-disposition'] = Buffer.from(obj['content-disposition']).toString('latin1')
358 }
359
360 return obj
361}
362
363function parseRawHeaders (headers) {
364 const len = headers.length
365 const ret = new Array(len)
366
367 let hasContentLength = false
368 let contentDispositionIdx = -1
369 let key
370 let val
371 let kLen = 0
372
373 for (let n = 0; n < headers.length; n += 2) {
374 key = headers[n]
375 val = headers[n + 1]
376
377 typeof key !== 'string' && (key = key.toString())
378 typeof val !== 'string' && (val = val.toString('utf8'))
379
380 kLen = key.length
381 if (kLen === 14 && key[7] === '-' && (key === 'content-length' || key.toLowerCase() === 'content-length')) {
382 hasContentLength = true
383 } else if (kLen === 19 && key[7] === '-' && (key === 'content-disposition' || key.toLowerCase() === 'content-disposition')) {
384 contentDispositionIdx = n + 1
385 }
386 ret[n] = key
387 ret[n + 1] = val
388 }
389
390 // See https://github.com/nodejs/node/pull/46528
391 if (hasContentLength && contentDispositionIdx !== -1) {
392 ret[contentDispositionIdx] = Buffer.from(ret[contentDispositionIdx]).toString('latin1')
393 }
394
395 return ret
396}
397
398function isBuffer (buffer) {
399 // See, https://github.com/mcollina/undici/pull/319
400 return buffer instanceof Uint8Array || Buffer.isBuffer(buffer)
401}
402
403function validateHandler (handler, method, upgrade) {
404 if (!handler || typeof handler !== 'object') {
405 throw new InvalidArgumentError('handler must be an object')
406 }
407
408 if (typeof handler.onConnect !== 'function') {
409 throw new InvalidArgumentError('invalid onConnect method')
410 }
411
412 if (typeof handler.onError !== 'function') {
413 throw new InvalidArgumentError('invalid onError method')
414 }
415
416 if (typeof handler.onBodySent !== 'function' && handler.onBodySent !== undefined) {
417 throw new InvalidArgumentError('invalid onBodySent method')
418 }
419
420 if (upgrade || method === 'CONNECT') {
421 if (typeof handler.onUpgrade !== 'function') {
422 throw new InvalidArgumentError('invalid onUpgrade method')
423 }
424 } else {
425 if (typeof handler.onHeaders !== 'function') {
426 throw new InvalidArgumentError('invalid onHeaders method')
427 }
428
429 if (typeof handler.onData !== 'function') {
430 throw new InvalidArgumentError('invalid onData method')
431 }
432
433 if (typeof handler.onComplete !== 'function') {
434 throw new InvalidArgumentError('invalid onComplete method')
435 }
436 }
437}
438
439// A body is disturbed if it has been read from and it cannot
440// be re-used without losing state or data.
441function isDisturbed (body) {
442 // TODO (fix): Why is body[kBodyUsed] needed?
443 return !!(body && (stream.isDisturbed(body) || body[kBodyUsed]))
444}
445
446function isErrored (body) {
447 return !!(body && stream.isErrored(body))
448}
449
450function isReadable (body) {
451 return !!(body && stream.isReadable(body))
452}
453
454function getSocketInfo (socket) {
455 return {
456 localAddress: socket.localAddress,
457 localPort: socket.localPort,
458 remoteAddress: socket.remoteAddress,
459 remotePort: socket.remotePort,
460 remoteFamily: socket.remoteFamily,
461 timeout: socket.timeout,
462 bytesWritten: socket.bytesWritten,
463 bytesRead: socket.bytesRead
464 }
465}
466
467/** @type {globalThis['ReadableStream']} */
468function ReadableStreamFrom (iterable) {
469 // We cannot use ReadableStream.from here because it does not return a byte stream.
470
471 let iterator
472 return new ReadableStream(
473 {
474 async start () {
475 iterator = iterable[Symbol.asyncIterator]()
476 },
477 async pull (controller) {
478 const { done, value } = await iterator.next()
479 if (done) {
480 queueMicrotask(() => {
481 controller.close()
482 controller.byobRequest?.respond(0)
483 })
484 } else {
485 const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)
486 if (buf.byteLength) {
487 controller.enqueue(new Uint8Array(buf))
488 }
489 }
490 return controller.desiredSize > 0
491 },
492 async cancel (reason) {
493 await iterator.return()
494 },
495 type: 'bytes'
496 }
497 )
498}
499
500// The chunk should be a FormData instance and contains
501// all the required methods.
502function isFormDataLike (object) {
503 return (
504 object &&
505 typeof object === 'object' &&
506 typeof object.append === 'function' &&
507 typeof object.delete === 'function' &&
508 typeof object.get === 'function' &&
509 typeof object.getAll === 'function' &&
510 typeof object.has === 'function' &&
511 typeof object.set === 'function' &&
512 object[Symbol.toStringTag] === 'FormData'
513 )
514}
515
516function addAbortListener (signal, listener) {
517 if ('addEventListener' in signal) {
518 signal.addEventListener('abort', listener, { once: true })
519 return () => signal.removeEventListener('abort', listener)
520 }
521 signal.addListener('abort', listener)
522 return () => signal.removeListener('abort', listener)
523}
524
525const hasToWellFormed = typeof String.prototype.toWellFormed === 'function'
526const hasIsWellFormed = typeof String.prototype.isWellFormed === 'function'
527
528/**
529 * @param {string} val
530 */
531function toUSVString (val) {
532 return hasToWellFormed ? `${val}`.toWellFormed() : nodeUtil.toUSVString(val)
533}
534
535/**
536 * @param {string} val
537 */
538// TODO: move this to webidl
539function isUSVString (val) {
540 return hasIsWellFormed ? `${val}`.isWellFormed() : toUSVString(val) === `${val}`
541}
542
543/**
544 * @see https://tools.ietf.org/html/rfc7230#section-3.2.6
545 * @param {number} c
546 */
547function isTokenCharCode (c) {
548 switch (c) {
549 case 0x22:
550 case 0x28:
551 case 0x29:
552 case 0x2c:
553 case 0x2f:
554 case 0x3a:
555 case 0x3b:
556 case 0x3c:
557 case 0x3d:
558 case 0x3e:
559 case 0x3f:
560 case 0x40:
561 case 0x5b:
562 case 0x5c:
563 case 0x5d:
564 case 0x7b:
565 case 0x7d:
566 // DQUOTE and "(),/:;<=>?@[\]{}"
567 return false
568 default:
569 // VCHAR %x21-7E
570 return c >= 0x21 && c <= 0x7e
571 }
572}
573
574/**
575 * @param {string} characters
576 */
577function isValidHTTPToken (characters) {
578 if (characters.length === 0) {
579 return false
580 }
581 for (let i = 0; i < characters.length; ++i) {
582 if (!isTokenCharCode(characters.charCodeAt(i))) {
583 return false
584 }
585 }
586 return true
587}
588
589// headerCharRegex have been lifted from
590// https://github.com/nodejs/node/blob/main/lib/_http_common.js
591
592/**
593 * Matches if val contains an invalid field-vchar
594 * field-value = *( field-content / obs-fold )
595 * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
596 * field-vchar = VCHAR / obs-text
597 */
598const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/
599
600/**
601 * @param {string} characters
602 */
603function isValidHeaderValue (characters) {
604 return !headerCharRegex.test(characters)
605}
606
607// Parsed accordingly to RFC 9110
608// https://www.rfc-editor.org/rfc/rfc9110#field.content-range
609function parseRangeHeader (range) {
610 if (range == null || range === '') return { start: 0, end: null, size: null }
611
612 const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null
613 return m
614 ? {
615 start: parseInt(m[1]),
616 end: m[2] ? parseInt(m[2]) : null,
617 size: m[3] ? parseInt(m[3]) : null
618 }
619 : null
620}
621
622function addListener (obj, name, listener) {
623 const listeners = (obj[kListeners] ??= [])
624 listeners.push([name, listener])
625 obj.on(name, listener)
626 return obj
627}
628
629function removeAllListeners (obj) {
630 for (const [name, listener] of obj[kListeners] ?? []) {
631 obj.removeListener(name, listener)
632 }
633 obj[kListeners] = null
634}
635
636function errorRequest (client, request, err) {
637 try {
638 request.onError(err)
639 assert(request.aborted)
640 } catch (err) {
641 client.emit('error', err)
642 }
643}
644
645const kEnumerableProperty = Object.create(null)
646kEnumerableProperty.enumerable = true
647
648const normalizedMethodRecordsBase = {
649 delete: 'DELETE',
650 DELETE: 'DELETE',
651 get: 'GET',
652 GET: 'GET',
653 head: 'HEAD',
654 HEAD: 'HEAD',
655 options: 'OPTIONS',
656 OPTIONS: 'OPTIONS',
657 post: 'POST',
658 POST: 'POST',
659 put: 'PUT',
660 PUT: 'PUT'
661}
662
663const normalizedMethodRecords = {
664 ...normalizedMethodRecordsBase,
665 patch: 'patch',
666 PATCH: 'PATCH'
667}
668
669// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
670Object.setPrototypeOf(normalizedMethodRecordsBase, null)
671Object.setPrototypeOf(normalizedMethodRecords, null)
672
673module.exports = {
674 kEnumerableProperty,
675 nop,
676 isDisturbed,
677 isErrored,
678 isReadable,
679 toUSVString,
680 isUSVString,
681 isBlobLike,
682 parseOrigin,
683 parseURL,
684 getServerName,
685 isStream,
686 isIterable,
687 isAsyncIterable,
688 isDestroyed,
689 headerNameToString,
690 bufferToLowerCasedHeaderName,
691 addListener,
692 removeAllListeners,
693 errorRequest,
694 parseRawHeaders,
695 parseHeaders,
696 parseKeepAliveTimeout,
697 destroy,
698 bodyLength,
699 deepClone,
700 ReadableStreamFrom,
701 isBuffer,
702 validateHandler,
703 getSocketInfo,
704 isFormDataLike,
705 buildURL,
706 addAbortListener,
707 isValidHTTPToken,
708 isValidHeaderValue,
709 isTokenCharCode,
710 parseRangeHeader,
711 normalizedMethodRecordsBase,
712 normalizedMethodRecords,
713 isValidPort,
714 isHttpOrHttpsPrefixed,
715 nodeMajor,
716 nodeMinor,
717 safeHTTPMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'],
718 wrapRequestBody
719}
720 