@libp2p/http-utils 0.0.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.ts ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * @packageDocumentation
3
+ *
4
+ * Contains shared code and utilities used by `@libp2p/http-*` modules.
5
+ */
6
+
7
+ import { HTTPParser } from '@achingbrain/http-parser-js'
8
+ import { InvalidParametersError, isPeerId, ProtocolError } from '@libp2p/interface'
9
+ import { peerIdFromString } from '@libp2p/peer-id'
10
+ import { fromStringTuples, isMultiaddr, multiaddr } from '@multiformats/multiaddr'
11
+ import { multiaddrToUri } from '@multiformats/multiaddr-to-uri'
12
+ import { uriToMultiaddr } from '@multiformats/uri-to-multiaddr'
13
+ import { queuelessPushable } from 'it-queueless-pushable'
14
+ import itToBrowserReadableStream from 'it-to-browser-readablestream'
15
+ import { base36 } from 'multiformats/bases/base36'
16
+ import { base64pad } from 'multiformats/bases/base64'
17
+ import { sha1 } from 'multiformats/hashes/sha1'
18
+ import { Uint8ArrayList } from 'uint8arraylist'
19
+ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
20
+ import { DNS_CODECS, HTTP_CODEC, HTTP_PATH_CODEC } from './constants.js'
21
+ import { Request } from './request.js'
22
+ import type { AbortOptions, PeerId, Stream } from '@libp2p/interface'
23
+ import type { Multiaddr } from '@multiformats/multiaddr'
24
+
25
+ /**
26
+ * A subset of options passed to middleware
27
+ */
28
+ export interface MiddlewareOptions extends AbortOptions {
29
+ method: string
30
+ headers: Headers
31
+ middleware: Middleware[]
32
+ credentials?: RequestCredentials
33
+ keepalive?: boolean
34
+ redirect?: RequestRedirect
35
+ integrity?: string
36
+ mode?: RequestMode
37
+ referrer?: string
38
+ referrerPolicy?: ReferrerPolicy
39
+ }
40
+
41
+ /**
42
+ * Middleware that allows augmenting the client request/response with additional
43
+ * fields or headers.
44
+ */
45
+ export interface Middleware {
46
+ /**
47
+ * Called before a request is made
48
+ */
49
+ prepareRequest?(resource: URL | Multiaddr[], opts: MiddlewareOptions): void | Promise<void>
50
+
51
+ /**
52
+ * Called after a request is made but before the body has been read - the
53
+ * processor may do any necessary housekeeping based on the server response
54
+ */
55
+ processResponse?(resource: URL | Multiaddr[], opts: MiddlewareOptions, response: Response): void | Promise<void>
56
+ }
57
+
58
+ export function toURL (resource: URL | Multiaddr[], headers: Headers): URL {
59
+ if (resource instanceof URL) {
60
+ return resource
61
+ }
62
+
63
+ const host = getHost(resource, headers)
64
+ const { httpPath } = stripHTTPPath(resource)
65
+
66
+ return new URL(`http://${host}${httpPath}`)
67
+ }
68
+
69
+ /**
70
+ * Normalizes byte-like input to a `Uint8Array`
71
+ */
72
+ export function toUint8Array (obj: DataView | ArrayBuffer | Uint8Array): Uint8Array {
73
+ if (obj instanceof Uint8Array) {
74
+ return obj
75
+ }
76
+
77
+ if (obj instanceof DataView) {
78
+ return new Uint8Array(obj.buffer, obj.byteOffset, obj.byteLength)
79
+ }
80
+
81
+ return new Uint8Array(obj, 0, obj.byteLength)
82
+ }
83
+
84
+ export function streamToRequest (info: HeaderInfo, stream: Stream): globalThis.Request {
85
+ const init: RequestInit = {
86
+ method: info.method,
87
+ headers: info.headers
88
+ }
89
+
90
+ if ((init.method !== 'GET' || info.upgrade) && init.method !== 'HEAD') {
91
+ let source: AsyncGenerator<any> = stream.source
92
+
93
+ if (!info.upgrade) {
94
+ source = takeBytes(stream.source, info.headers.get('content-length'))
95
+ }
96
+
97
+ init.body = itToBrowserReadableStream<Uint8Array>(source)
98
+ // @ts-expect-error this is required by NodeJS despite being the only reasonable option https://fetch.spec.whatwg.org/#requestinit
99
+ init.duplex = 'half'
100
+ }
101
+
102
+ return new Request(normalizeUrl(info).toString(), init)
103
+ }
104
+
105
+ export async function responseToStream (res: Response, stream: Stream): Promise<void> {
106
+ const pushable = queuelessPushable<Uint8Array>()
107
+ stream.sink(pushable)
108
+ .catch(err => {
109
+ stream.abort(err)
110
+ })
111
+
112
+ await pushable.push(uint8ArrayFromString([
113
+ `HTTP/1.1 ${res.status} ${res.statusText}`,
114
+ ...writeHeaders(res.headers),
115
+ '',
116
+ ''
117
+ ].join('\r\n')))
118
+
119
+ if (res.body == null) {
120
+ await pushable.end()
121
+ return
122
+ }
123
+
124
+ const reader = res.body.getReader()
125
+ let result = await reader.read()
126
+
127
+ while (true) {
128
+ if (result.value != null) {
129
+ await pushable.push(result.value)
130
+ }
131
+
132
+ if (result.done) {
133
+ break
134
+ }
135
+
136
+ result = await reader.read()
137
+ }
138
+
139
+ await pushable.end()
140
+
141
+ await stream.closeWrite()
142
+ .catch(err => {
143
+ stream.abort(err)
144
+ })
145
+ }
146
+
147
+ export const NOT_FOUND_RESPONSE = uint8ArrayFromString([
148
+ 'HTTP/1.1 404 Not Found',
149
+ 'Connection: close',
150
+ '',
151
+ ''
152
+ ].join('\r\n'))
153
+
154
+ export const BAD_REQUEST = uint8ArrayFromString([
155
+ 'HTTP/1.1 400 Bad Request',
156
+ 'Connection: close',
157
+ '',
158
+ ''
159
+ ].join('\r\n'))
160
+
161
+ export const INTERNAL_SERVER_ERROR = uint8ArrayFromString([
162
+ 'HTTP/1.1 500 Internal Server Error',
163
+ 'Connection: close',
164
+ '',
165
+ ''
166
+ ].join('\r\n'))
167
+
168
+ export const NOT_IMPLEMENTED_ERROR = uint8ArrayFromString([
169
+ 'HTTP/1.1 501 Not Implemented',
170
+ 'Connection: close',
171
+ '',
172
+ ''
173
+ ].join('\r\n'))
174
+
175
+ export function writeHeaders (headers: Headers): string[] {
176
+ const output = []
177
+
178
+ if (headers.get('Connection') == null) {
179
+ headers.set('Connection', 'close')
180
+ }
181
+
182
+ for (const [key, value] of headers.entries()) {
183
+ output.push(`${key}: ${value}`)
184
+ }
185
+
186
+ return output
187
+ }
188
+
189
+ async function * takeBytes (source: AsyncGenerator<Uint8ArrayList>, bytes?: number | string | null): AsyncGenerator<Uint8Array> {
190
+ bytes = parseInt(`${bytes ?? ''}`)
191
+
192
+ if (bytes == null || isNaN(bytes)) {
193
+ return source
194
+ }
195
+
196
+ let count = 0
197
+
198
+ for await (const buf of source) {
199
+ count += buf.byteLength
200
+
201
+ if (count > bytes) {
202
+ yield buf.subarray(0, count - bytes)
203
+ return
204
+ }
205
+
206
+ yield buf.subarray()
207
+
208
+ if (count === bytes) {
209
+ return
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Attempts to convert the passed `resource` into a HTTP(s) URL or an array of
216
+ * multiaddrs.
217
+ *
218
+ * The returned URL should be handled by the global fetch, the multiaddr(s)
219
+ * should be handled by libp2p.
220
+ */
221
+ export function toResource (resource: string | URL | PeerId | Multiaddr | Multiaddr[], path?: string): URL | Multiaddr[] {
222
+ if (typeof resource === 'string') {
223
+ if (resource.startsWith('/')) {
224
+ resource = multiaddr(resource)
225
+ } else {
226
+ resource = new URL(resource)
227
+ }
228
+ }
229
+
230
+ if (isPeerId(resource)) {
231
+ resource = multiaddr(`/p2p/${resource}`)
232
+ }
233
+
234
+ if (resource instanceof URL) {
235
+ if (resource.protocol === 'multiaddr:') {
236
+ resource = uriToMultiaddr(resource.toString())
237
+ }
238
+ }
239
+
240
+ if (isMultiaddr(resource)) {
241
+ resource = [resource]
242
+ }
243
+
244
+ // check for `/http/` tuple and transform to URL if present
245
+ if (Array.isArray(resource)) {
246
+ for (const ma of resource) {
247
+ const stringTuples = ma.stringTuples()
248
+
249
+ if (stringTuples.find(([codec]) => codec === HTTP_CODEC) != null) {
250
+ const uri = multiaddrToUri(ma)
251
+ return new URL(`${uri}${path ?? ''}`)
252
+ }
253
+ }
254
+ }
255
+
256
+ if (path == null) {
257
+ return resource
258
+ }
259
+
260
+ if (resource instanceof URL) {
261
+ return new URL(`${resource}${path.substring(1)}`)
262
+ }
263
+
264
+ return resource.map(ma => ma.encapsulate(`/http-path/${encodeURIComponent(path.substring(1))}`))
265
+ }
266
+
267
+ export function getHeaders (init: RequestInit = {}): Headers {
268
+ if (init.headers instanceof Headers) {
269
+ return init.headers
270
+ }
271
+
272
+ init.headers = new Headers(init.headers)
273
+
274
+ return init.headers
275
+ }
276
+
277
+ export function getHeader (header: string, headers: HeadersInit = {}): string | undefined {
278
+ if (headers instanceof Headers) {
279
+ return headers.get(header) ?? undefined
280
+ }
281
+
282
+ if (Array.isArray(headers)) {
283
+ return headers.find(([key, value]) => {
284
+ if (key === header) {
285
+ return value
286
+ }
287
+
288
+ return undefined
289
+ })?.[1]
290
+ }
291
+
292
+ return headers[header]
293
+ }
294
+
295
+ function isValidHost (host?: string): host is string {
296
+ return host != null && host !== ''
297
+ }
298
+
299
+ // eslint-disable-next-line complexity
300
+ export function getHost (addresses: URL | Multiaddr[], headers: Headers): string {
301
+ let host: string | undefined
302
+ let port = 80
303
+ let protocol = 'http:'
304
+
305
+ if (addresses instanceof URL) {
306
+ host = addresses.hostname
307
+ port = parseInt(addresses.port, 10)
308
+ protocol = addresses.protocol
309
+ }
310
+
311
+ if (!isValidHost(host)) {
312
+ host = headers.get('host') ?? undefined
313
+ }
314
+
315
+ // try to extract domain from DNS addresses
316
+ if (!isValidHost(host) && Array.isArray(addresses)) {
317
+ for (const address of addresses) {
318
+ const stringTuples = address.stringTuples()
319
+ const filtered = stringTuples.filter(([key]) => DNS_CODECS.includes(key))?.[0]?.[1]
320
+
321
+ if (filtered != null) {
322
+ host = filtered
323
+ break
324
+ }
325
+ }
326
+ }
327
+
328
+ // try to use remote PeerId as domain
329
+ if (!isValidHost(host) && Array.isArray(addresses)) {
330
+ for (const address of addresses) {
331
+ const peerStr = address.getPeerId()
332
+
333
+ // try to extract port from multiaddr if it is available
334
+ try {
335
+ const options = address.toOptions()
336
+ port = options.port
337
+ } catch {}
338
+
339
+ if (peerStr != null) {
340
+ const peerId = peerIdFromString(peerStr)
341
+ // host has to be case-insensitive
342
+ host = peerId.toCID().toString(base36)
343
+ break
344
+ }
345
+ }
346
+ }
347
+
348
+ // try use network host as domain
349
+ if (!isValidHost(host) && Array.isArray(addresses)) {
350
+ for (const address of addresses) {
351
+ try {
352
+ const options = address.toOptions()
353
+
354
+ host = options.host
355
+ break
356
+ } catch {}
357
+ }
358
+ }
359
+
360
+ if (isValidHost(host)) {
361
+ // add port if not standard
362
+ if (protocol === 'http:' && port !== 80) {
363
+ host = `${host}:${port}`
364
+ }
365
+
366
+ if (protocol === 'https:' && port !== 443) {
367
+ host = `${host}:${port}`
368
+ }
369
+
370
+ return host
371
+ }
372
+
373
+ throw new InvalidParametersError('Could not determine request host name - a request must have a host header, be made to a DNS or IP-based multiaddr or an http(s) URL')
374
+ }
375
+
376
+ export function stripHTTPPath (addresses: Multiaddr[]): { httpPath: string, addresses: Multiaddr[] } {
377
+ // strip http-path tuple but record the value if set
378
+ let httpPath = '/'
379
+ addresses = addresses.map(ma => {
380
+ return fromStringTuples(
381
+ ma.stringTuples().filter(t => {
382
+ if (t[0] === HTTP_PATH_CODEC && t[1] != null) {
383
+ httpPath = `/${t[1]}`
384
+ }
385
+
386
+ return t[0] !== HTTP_PATH_CODEC
387
+ })
388
+ )
389
+ })
390
+
391
+ return {
392
+ httpPath,
393
+ addresses
394
+ }
395
+ }
396
+
397
+ export function normalizeMethod (method?: string | string[], defaultMethod = ['GET']): string[] {
398
+ if (method == null) {
399
+ return defaultMethod
400
+ }
401
+
402
+ if (typeof method === 'string') {
403
+ method = [method]
404
+ }
405
+
406
+ return method.map(m => m.toUpperCase())
407
+ }
408
+
409
+ /**
410
+ * Returns a fully qualified URL representing the resource that is being
411
+ * requested
412
+ */
413
+ export function normalizeUrl (req: { url?: string, headers?: Headers | { host?: string } }): URL {
414
+ const url = req.url ?? '/'
415
+
416
+ if (url.startsWith('http')) {
417
+ return new URL(url)
418
+ }
419
+
420
+ const host = getHostFromReq(req)
421
+
422
+ return new URL(`http://${host}${url}`)
423
+ }
424
+
425
+ function getHostFromReq (req: any): string {
426
+ let host = req.headers?.host
427
+
428
+ if (host == null) {
429
+ host = req.headers?.Host
430
+ }
431
+
432
+ if (host == null && typeof req.headers.get === 'function') {
433
+ host = req.headers.get('host')
434
+ }
435
+
436
+ if (host == null) {
437
+ throw new InvalidParametersError('Could not read host')
438
+ }
439
+
440
+ return host
441
+ }
442
+
443
+ export function isWebSocketUpgrade (method: string, headers: Headers): boolean {
444
+ return method === 'GET' && headers.get('connection')?.toLowerCase() === 'upgrade' && headers.get('upgrade')?.toLowerCase() === 'websocket'
445
+ }
446
+
447
+ /**
448
+ * Handles node.js-style headers for which the values can be string[]
449
+ */
450
+ function getHeaderFromHeaders (headers: Headers | Record<string, string | string[] | undefined>, key: string): string | undefined {
451
+ if (headers instanceof Headers) {
452
+ return headers.get(key) ?? undefined
453
+ }
454
+
455
+ const header = headers[key]
456
+
457
+ if (Array.isArray(header)) {
458
+ return header.join(',')
459
+ }
460
+
461
+ return header
462
+ }
463
+
464
+ export async function getServerUpgradeHeaders (headers: Headers | Record<string, string | string[] | undefined>): Promise<Headers> {
465
+ if (getHeaderFromHeaders(headers, 'sec-websocket-version') !== '13') {
466
+ throw new ProtocolError('Invalid version')
467
+ }
468
+
469
+ const secWebSocketKey = getHeaderFromHeaders(headers, 'sec-websocket-key')
470
+
471
+ if (secWebSocketKey == null) {
472
+ throw new ProtocolError('Missing sec-websocket-key')
473
+ }
474
+
475
+ const token = `${secWebSocketKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`
476
+ const hash = await sha1.digest(uint8ArrayFromString(token))
477
+ const webSocketAccept = base64pad.encode(
478
+ hash.digest
479
+ ).substring(1)
480
+
481
+ return new Headers({
482
+ Upgrade: 'websocket',
483
+ Connection: 'upgrade',
484
+ 'Sec-WebSocket-Accept': webSocketAccept
485
+ })
486
+ }
487
+
488
+ /**
489
+ * Reads HTTP headers from an incoming stream
490
+ */
491
+ export async function readHeaders (stream: Stream): Promise<HeaderInfo> {
492
+ return new Promise<any>((resolve, reject) => {
493
+ const parser = new HTTPParser('REQUEST')
494
+ const source = queuelessPushable<Uint8ArrayList>()
495
+ const earlyData = new Uint8ArrayList()
496
+ let headersComplete = false
497
+
498
+ parser[HTTPParser.kOnHeadersComplete] = (info) => {
499
+ headersComplete = true
500
+ const headers = new Headers()
501
+
502
+ // set incoming headers
503
+ for (let i = 0; i < info.headers.length; i += 2) {
504
+ headers.set(info.headers[i].toLowerCase(), info.headers[i + 1])
505
+ }
506
+
507
+ resolve({
508
+ ...info,
509
+ headers,
510
+ raw: earlyData,
511
+ method: HTTPParser.methods[info.method]
512
+ })
513
+ }
514
+
515
+ // replace source with request body
516
+ const streamSource = stream.source
517
+ stream.source = source
518
+
519
+ Promise.resolve().then(async () => {
520
+ for await (const chunk of streamSource) {
521
+ // only use the message parser until the headers have been read
522
+ if (!headersComplete) {
523
+ earlyData.append(chunk)
524
+ parser.execute(chunk.subarray())
525
+ } else {
526
+ await source.push(new Uint8ArrayList(chunk))
527
+ }
528
+ }
529
+
530
+ await source.end()
531
+ })
532
+ .catch((err: Error) => {
533
+ stream.abort(err)
534
+ reject(err)
535
+ })
536
+ .finally(() => {
537
+ parser.finish()
538
+ })
539
+ })
540
+ }
541
+
542
+ /**
543
+ * Parsed from the incoming HTTP message
544
+ */
545
+ export interface HeaderInfo {
546
+ versionMajor: number
547
+ versionMinor: number
548
+ headers: Headers
549
+ method: string
550
+ url: string
551
+ statusCode: number
552
+ statusMessage: string
553
+ upgrade: boolean
554
+ shouldKeepAlive: boolean
555
+ raw: Uint8ArrayList
556
+ }
557
+
558
+ export * from './request.js'
559
+ export * from './response.js'
560
+ export * from './constants.js'
561
+ export * from './stream-to-socket.js'
package/src/request.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { getHeaders, isWebSocketUpgrade } from './index.js'
2
+
3
+ /**
4
+ * Extends the native Request class to be more flexible.
5
+ *
6
+ * - body - normally GET requests cannot have a body, but if the request is for
7
+ * a WebSocket upgrade, we need the body to turn into the socket
8
+ *
9
+ * Also firefox Web Workers remove the request body though weirdly the main
10
+ * thread doesn't.
11
+ *
12
+ * - headers - the global browser request removes certain headers like
13
+ * Authorization and Sec-WebSocket-Protocol but we need to preserve them
14
+ */
15
+ export class Request extends globalThis.Request {
16
+ constructor (input: RequestInfo | URL, init: RequestInit = {}) {
17
+ const method = init.method ?? 'GET'
18
+ const headers = getHeaders(init)
19
+ const body = init.body
20
+
21
+ if (isWebSocketUpgrade(method, headers)) {
22
+ // temporarily override the method name since undici does not allow GET
23
+ // requests with bodies
24
+ init.method = 'UPGRADE'
25
+ }
26
+
27
+ super(input, init)
28
+
29
+ Object.defineProperties(this, {
30
+ body: {
31
+ value: body,
32
+ writable: false
33
+ },
34
+ method: {
35
+ value: method,
36
+ writable: false
37
+ },
38
+ headers: {
39
+ value: headers,
40
+ writable: false
41
+ }
42
+ })
43
+ }
44
+ }
@@ -0,0 +1,40 @@
1
+ import { STATUS_CODES } from './constants.js'
2
+ import { getHeaders } from './index.js'
3
+
4
+ /**
5
+ * Extends the native Response class to be more flexible.
6
+ *
7
+ * - response headers - the fetch spec restricts access to certain headers that
8
+ * we need access to `set-cookie`, `Access-Control-*`, etc, and the native
9
+ * Response implementations remove them
10
+ *
11
+ * - status codes - we need to represent all possible HTTP status codes, not
12
+ * just those allowed by the fetch spec
13
+ */
14
+ export class Response extends globalThis.Response {
15
+ constructor (body: BodyInit | null, init: ResponseInit = {}) {
16
+ const headers = getHeaders(init)
17
+ const status = init.status ?? 200
18
+
19
+ if (status < 200 || status > 599) {
20
+ init.status = 200
21
+ }
22
+
23
+ super(body, init)
24
+
25
+ Object.defineProperties(this, {
26
+ status: {
27
+ value: status,
28
+ writable: false
29
+ },
30
+ statusText: {
31
+ value: STATUS_CODES[status],
32
+ writable: false
33
+ },
34
+ headers: {
35
+ value: headers,
36
+ writable: false
37
+ }
38
+ })
39
+ }
40
+ }