File
Blob: src/node/internal/internal_http_client.ts
| 1 | // Copyright (c) 2017-2022 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | // Copyright Joyent and Node contributors. All rights reserved. MIT license. |
| 5 | |
| 6 | import { _checkIsHttpToken as checkIsHttpToken } from 'node-internal:internal_http'; |
| 7 | import { |
| 8 | kOutHeaders, |
| 9 | kUniqueHeaders, |
| 10 | parseUniqueHeadersOption, |
| 11 | } from 'node-internal:internal_http_outgoing'; |
| 12 | import { Buffer } from 'node-internal:internal_buffer'; |
| 13 | import { urlToHttpOptions, isURL } from 'node-internal:internal_url'; |
| 14 | import { |
| 15 | ERR_INVALID_ARG_TYPE, |
| 16 | ERR_INVALID_HTTP_TOKEN, |
| 17 | ERR_OPTION_NOT_IMPLEMENTED, |
| 18 | ERR_UNESCAPED_CHARACTERS, |
| 19 | ERR_INVALID_PROTOCOL, |
| 20 | ERR_INVALID_ARG_VALUE, |
| 21 | ERR_HTTP_HEADERS_SENT, |
| 22 | ERR_METHOD_NOT_IMPLEMENTED, |
| 23 | } from 'node-internal:internal_errors'; |
| 24 | import { |
| 25 | validateInteger, |
| 26 | validateBoolean, |
| 27 | validateFunction, |
| 28 | validateString, |
| 29 | validateNumber, |
| 30 | } from 'node-internal:validators'; |
| 31 | import { getTimerDuration } from 'node-internal:internal_net'; |
| 32 | import { addAbortSignal } from 'node-internal:streams_add_abort_signal'; |
| 33 | import { Writable } from 'node-internal:streams_writable'; |
| 34 | import type { |
| 35 | ClientRequest as _ClientRequest, |
| 36 | RequestOptions, |
| 37 | OutgoingHttpHeaders, |
| 38 | } from 'node:http'; |
| 39 | import { |
| 40 | IncomingMessage, |
| 41 | setIncomingMessageFetchResponse, |
| 42 | } from 'node-internal:internal_http_incoming'; |
| 43 | import { OutgoingMessage } from 'node-internal:internal_http_outgoing'; |
| 44 | import { Agent, globalAgent } from 'node-internal:internal_http_agent'; |
| 45 | import type { IncomingMessageCallback } from 'node-internal:internal_http_util'; |
| 46 | import type { Socket } from 'node:net'; |
| 47 | |
| 48 | const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/; |
| 49 | |
| 50 | type WriteCallback = (err?: Error) => void; |
| 51 | |
| 52 | function validateHost(host: unknown, name: string): string { |
| 53 | if (host != null && typeof host !== 'string') { |
| 54 | throw new ERR_INVALID_ARG_TYPE( |
| 55 | `options.${name}`, |
| 56 | ['string', 'undefined', 'null'], |
| 57 | host |
| 58 | ); |
| 59 | } |
| 60 | return host as string; |
| 61 | } |
| 62 | |
| 63 | // @ts-expect-error TS2720 Complaining due to "override req" being undefined. |
| 64 | export class ClientRequest extends OutgoingMessage implements _ClientRequest { |
| 65 | #abortController = new AbortController(); |
| 66 | #body: (Buffer | Uint8Array)[] = []; |
| 67 | #incomingMessage?: IncomingMessage; |
| 68 | #timer: number | null = null; |
| 69 | |
| 70 | _ended: boolean = false; |
| 71 | |
| 72 | timeout?: number; |
| 73 | method: string = 'GET'; |
| 74 | path: string = '/'; |
| 75 | host: string; |
| 76 | protocol: string = 'http:'; |
| 77 | port: string = '80'; |
| 78 | joinDuplicateHeaders: boolean | undefined; |
| 79 | agent: Agent | undefined; |
| 80 | |
| 81 | // Unused fields required to be Node.js compatible. |
| 82 | override aborted: boolean = false; |
| 83 | reusedSocket: boolean = false; |
| 84 | maxHeadersCount: number = Infinity; |
| 85 | connection: Socket | null = null; |
| 86 | socket: Socket | null = null; |
| 87 | |
| 88 | [kUniqueHeaders]: Set<string> | null = null; |
| 89 | |
| 90 | constructor( |
| 91 | input: string | URL | RequestOptions | null, |
| 92 | options?: RequestOptions | IncomingMessageCallback, |
| 93 | cb?: IncomingMessageCallback |
| 94 | ) { |
| 95 | super(); |
| 96 | |
| 97 | if (typeof input === 'string') { |
| 98 | input = urlToHttpOptions(new URL(input)); |
| 99 | } else if (isURL(input)) { |
| 100 | // url.URL instance |
| 101 | input = urlToHttpOptions(input); |
| 102 | } else { |
| 103 | cb = options as IncomingMessageCallback; |
| 104 | options = input as RequestOptions; |
| 105 | input = null; |
| 106 | } |
| 107 | |
| 108 | if (typeof options === 'function') { |
| 109 | cb = options; |
| 110 | options = input ?? {}; |
| 111 | } else { |
| 112 | options = Object.assign(input ?? {}, options); |
| 113 | } |
| 114 | |
| 115 | if (options.path) { |
| 116 | if (INVALID_PATH_REGEX.test(options.path)) { |
| 117 | throw new ERR_UNESCAPED_CHARACTERS('Request path'); |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | type AgentLike = Agent | boolean | null | undefined; |
| 122 | let agent = options.agent as unknown as AgentLike; |
| 123 | // TODO(soon): Rather than using RequestOptions use our own type that includes our own Agent class type. |
| 124 | const defaultAgent = |
| 125 | (options._defaultAgent as unknown as AgentLike) || globalAgent; |
| 126 | if (agent === false) { |
| 127 | // @ts-expect-error TS2351 This expression is not constructable. |
| 128 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call |
| 129 | agent = new defaultAgent.constructor(); |
| 130 | } else if (agent == null) { |
| 131 | if (typeof options.createConnection !== 'function') { |
| 132 | agent = defaultAgent as Agent; |
| 133 | } |
| 134 | } else if ( |
| 135 | typeof agent === 'object' && |
| 136 | typeof agent.addRequest !== 'function' |
| 137 | ) { |
| 138 | throw new ERR_INVALID_ARG_TYPE( |
| 139 | 'options.agent', |
| 140 | ['Agent-like Object', 'undefined', 'false'], |
| 141 | agent |
| 142 | ); |
| 143 | } |
| 144 | this.agent = agent as Agent | undefined; |
| 145 | |
| 146 | let expectedProtocol = (defaultAgent as Agent).protocol; |
| 147 | const protocol = options.protocol || expectedProtocol; |
| 148 | if (this.agent?.protocol) expectedProtocol = this.agent.protocol; |
| 149 | const defaultPort = options.defaultPort || this.agent?.defaultPort || 80; |
| 150 | |
| 151 | if (protocol !== expectedProtocol) { |
| 152 | throw new ERR_INVALID_PROTOCOL(protocol, expectedProtocol); |
| 153 | } |
| 154 | this.protocol = protocol; |
| 155 | const port = (options.port = options.port || defaultPort || 80); |
| 156 | this.port = port.toString(); |
| 157 | const host = (options.host = |
| 158 | validateHost(options.hostname, 'hostname') || |
| 159 | validateHost(options.host, 'host') || |
| 160 | 'localhost'); |
| 161 | |
| 162 | const setHost = |
| 163 | options.setHost !== undefined |
| 164 | ? Boolean(options.setHost) // eslint-disable-line @typescript-eslint/no-unnecessary-type-conversion |
| 165 | : options.setDefaultHeaders !== false; |
| 166 | if (options.timeout !== undefined) |
| 167 | this.timeout = getTimerDuration(options.timeout, 'timeout'); |
| 168 | |
| 169 | const signal = options.signal; |
| 170 | if (signal) { |
| 171 | addAbortSignal(signal, this as unknown as Writable); |
| 172 | } |
| 173 | let method = options.method; |
| 174 | const methodIsString = typeof method === 'string'; |
| 175 | if (method != null && !methodIsString) { |
| 176 | throw new ERR_INVALID_ARG_TYPE('options.method', 'string', method); |
| 177 | } |
| 178 | |
| 179 | if (methodIsString && method) { |
| 180 | if (!checkIsHttpToken(method)) { |
| 181 | throw new ERR_INVALID_HTTP_TOKEN('Method', method); |
| 182 | } |
| 183 | method = this.method = method.toUpperCase(); |
| 184 | } else { |
| 185 | method = this.method = 'GET'; |
| 186 | } |
| 187 | |
| 188 | const maxHeaderSize = options.maxHeaderSize; |
| 189 | if (maxHeaderSize !== undefined) { |
| 190 | // This overrides the maximum length of response headers in bytes. |
| 191 | // It doesn't make sense to override the maximum length for Workerd implementation |
| 192 | // which is based on the original "fetch" API. |
| 193 | validateInteger(maxHeaderSize, 'maxHeaderSize', 0); |
| 194 | throw new ERR_OPTION_NOT_IMPLEMENTED('options.maxHeaderSize'); |
| 195 | } |
| 196 | |
| 197 | if (options.insecureHTTPParser !== undefined) { |
| 198 | // If enabled it will use a HTTP parser with leniency flags enabled. |
| 199 | // Since our implementation does not use any http parser, and uses "fetch" API, |
| 200 | // it doesn't make sense to support this option. |
| 201 | validateBoolean(options.insecureHTTPParser, 'options.insecureHTTPParser'); |
| 202 | } |
| 203 | |
| 204 | if (options.createConnection !== undefined) { |
| 205 | // Our implementation is based on the original "fetch" API, which doesn't support |
| 206 | // custom socket creation. Therefore, this option is not applicable. |
| 207 | validateFunction(options.createConnection, 'options.createConnection'); |
| 208 | throw new ERR_OPTION_NOT_IMPLEMENTED('options.createConnection'); |
| 209 | } |
| 210 | |
| 211 | if (options.lookup !== undefined) { |
| 212 | // Our implementation is based on the original "fetch" API, which doesn't support |
| 213 | // custom DNS resolution. Therefore, this option is not applicable. |
| 214 | validateFunction(options.lookup, 'options.lookup'); |
| 215 | throw new ERR_OPTION_NOT_IMPLEMENTED('options.lookup'); |
| 216 | } |
| 217 | |
| 218 | if (options.socketPath !== undefined) { |
| 219 | // Unix domain socket. Cannot be used if one of host or port is specified, as those specify a TCP Socket. |
| 220 | // This option is not applicable for our "fetch" based implementation. |
| 221 | validateString(options.socketPath, 'options.socketPath'); |
| 222 | throw new ERR_OPTION_NOT_IMPLEMENTED('options.socketPath'); |
| 223 | } |
| 224 | |
| 225 | if (options.joinDuplicateHeaders !== undefined) { |
| 226 | validateBoolean( |
| 227 | options.joinDuplicateHeaders, |
| 228 | 'options.joinDuplicateHeaders' |
| 229 | ); |
| 230 | } |
| 231 | this.joinDuplicateHeaders = options.joinDuplicateHeaders; |
| 232 | |
| 233 | this.path = options.path || '/'; |
| 234 | if (cb) { |
| 235 | this.once('response', cb); |
| 236 | } |
| 237 | |
| 238 | this.host = host; |
| 239 | |
| 240 | const headers = options.headers; |
| 241 | if (!Array.isArray(headers)) { |
| 242 | if (headers != null) { |
| 243 | if ('host' in headers) { |
| 244 | validateString(headers.host, 'host'); |
| 245 | } |
| 246 | for (const [key, value] of Object.entries(headers)) { |
| 247 | this.setHeader(key, value as unknown as string); |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | if (host && !this.getHeader('host') && setHost) { |
| 252 | let hostHeader = host; |
| 253 | |
| 254 | // For the Host header, ensure that IPv6 addresses are enclosed |
| 255 | // in square brackets, as defined by URI formatting |
| 256 | // https://tools.ietf.org/html/rfc3986#section-3.2.2 |
| 257 | const posColon = hostHeader.indexOf(':'); |
| 258 | if ( |
| 259 | posColon !== -1 && |
| 260 | hostHeader.includes(':', posColon + 1) && |
| 261 | hostHeader.charCodeAt(0) !== 91 /* '[' */ |
| 262 | ) { |
| 263 | hostHeader = `[${hostHeader}]`; |
| 264 | } |
| 265 | |
| 266 | if (port && +port !== defaultPort) { |
| 267 | // eslint-disable-next-line @typescript-eslint/restrict-plus-operands |
| 268 | hostHeader += ':' + port; |
| 269 | } |
| 270 | this.setHeader('Host', hostHeader); |
| 271 | } |
| 272 | |
| 273 | if (options.auth && !this.getHeader('Authorization')) { |
| 274 | this.setHeader( |
| 275 | 'Authorization', |
| 276 | 'Basic ' + Buffer.from(options.auth).toString('base64') |
| 277 | ); |
| 278 | } |
| 279 | } else { |
| 280 | if (headers.length % 2 !== 0) { |
| 281 | throw new ERR_INVALID_ARG_VALUE('headers', headers); |
| 282 | } |
| 283 | |
| 284 | for (let n = 0; n < headers.length; n += 2) { |
| 285 | this.setHeader(headers[n + 0] as string, headers[n + 1] as string); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | this.on('finish', () => { |
| 290 | this.#onFinish(); |
| 291 | }); |
| 292 | |
| 293 | this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders); |
| 294 | } |
| 295 | |
| 296 | #onFinish(): void { |
| 297 | if (this.destroyed) return; |
| 298 | |
| 299 | let body: BodyInit | null = null; |
| 300 | if (this.method !== 'GET' && this.method !== 'HEAD') { |
| 301 | if (this.#body.length > 0) { |
| 302 | const value = this.getHeader('content-type') ?? ''; |
| 303 | body = new Blob(this.#body as BlobPart[], { |
| 304 | type: Array.isArray(value) ? value.join(', ') : `${value}`, |
| 305 | }); |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | const headers: [string, string][] = []; |
| 310 | for (const [_lowerCaseName, [originalName, value]] of Object.entries( |
| 311 | this[kOutHeaders] ?? {} |
| 312 | )) { |
| 313 | if (Array.isArray(value)) { |
| 314 | if (this.joinDuplicateHeaders) { |
| 315 | headers.push([originalName, value.join(', ')]); |
| 316 | } else { |
| 317 | for (const item of value) { |
| 318 | headers.push([originalName, item]); |
| 319 | } |
| 320 | } |
| 321 | } else { |
| 322 | headers.push([originalName, value]); |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | if (this.timeout) { |
| 327 | this.#timer = setTimeout(() => { |
| 328 | this.emit('timeout'); |
| 329 | this.#incomingMessage?.emit('timeout'); |
| 330 | this.#abortController.abort(); |
| 331 | }, this.timeout) as unknown as number; |
| 332 | } |
| 333 | |
| 334 | if ( |
| 335 | this.host && |
| 336 | !this.getHeader('host') && |
| 337 | Object.keys(this[kOutHeaders] ?? {}).length === 0 |
| 338 | ) { |
| 339 | // From RFC 7230 5.4 https://datatracker.ietf.org/doc/html/rfc7230#section-5.4 |
| 340 | // A server MUST respond with a 400 (Bad Request) status code to any |
| 341 | // HTTP/1.1 request message that lacks a Host header field |
| 342 | queueMicrotask(() => { |
| 343 | this.#handleFetchResponse( |
| 344 | new Response(null, { |
| 345 | status: 400, |
| 346 | statusText: 'Bad Request', |
| 347 | headers: { |
| 348 | connection: 'close', |
| 349 | }, |
| 350 | }) |
| 351 | ); |
| 352 | }); |
| 353 | return; |
| 354 | } |
| 355 | |
| 356 | const host = this.getHeader('host') ?? this.host; |
| 357 | let url = new URL(`http://${host}`); |
| 358 | url.protocol = this.protocol; |
| 359 | url.port = this.port; |
| 360 | |
| 361 | if (this.path.length > 0 && this.path !== '/') { |
| 362 | // We pass `path` as the first argument since it can contain search and hash components. |
| 363 | // Therefore, running the pathname setter will not work. |
| 364 | // Since this is an extremely costly operation, we only do it if necessary. |
| 365 | url = new URL(this.path, url); |
| 366 | } |
| 367 | |
| 368 | // Our fetch implementation has the following limitation: |
| 369 | // |
| 370 | // Nothing is directly waiting for fetch promise here. |
| 371 | // It's up to the user of the HTTP API to arrange for |
| 372 | // the request to be held open until the fetch completes, |
| 373 | // typically by passing some promise to ctx.waitUntil() |
| 374 | // and resolving that promise when the request is complete. |
| 375 | // |
| 376 | // TODO(soon): Address this limitation. |
| 377 | |
| 378 | // We use encodeResponseBody: 'manual' to prevent fetch from automatically |
| 379 | // decompressing the response body. Node.js http does not auto-decompress; |
| 380 | // callers are expected to handle Content-Encoding themselves. |
| 381 | // The type assertion is needed because the DOM RequestInit type does not |
| 382 | // include the workerd-specific encodeResponseBody property. |
| 383 | fetch(url, { |
| 384 | method: this.method, |
| 385 | headers, |
| 386 | body: body ?? null, |
| 387 | signal: this.#abortController.signal, |
| 388 | redirect: 'manual', |
| 389 | encodeResponseBody: 'manual', |
| 390 | } as RequestInit & { encodeResponseBody: 'manual' }) |
| 391 | .then(this.#handleFetchResponse.bind(this)) |
| 392 | .catch(this.#handleFetchError.bind(this)); |
| 393 | } |
| 394 | |
| 395 | #handleFetchResponse(response: Response): void { |
| 396 | // Sets headersSent |
| 397 | this._header = Array.from(response.headers.keys()) |
| 398 | .map((key) => `${key}=${response.headers.get(key)}}`) |
| 399 | .join('\r\n'); |
| 400 | const incoming = new IncomingMessage(); |
| 401 | setIncomingMessageFetchResponse(incoming, response); |
| 402 | incoming.on('error', (error) => { |
| 403 | this.emit('error', error); |
| 404 | }); |
| 405 | |
| 406 | this.emit('response', incoming); |
| 407 | // @ts-expect-error TS2540 This is a read-only property. |
| 408 | this.req = this.#incomingMessage; |
| 409 | this.#incomingMessage = incoming; |
| 410 | } |
| 411 | |
| 412 | #handleFetchError(error: Error): void { |
| 413 | if (!this.destroyed) { |
| 414 | this.emit('error', error); |
| 415 | } else { |
| 416 | console.log(error); |
| 417 | } |
| 418 | this.destroyed = true; |
| 419 | this._ended = true; |
| 420 | } |
| 421 | |
| 422 | onSocket(_socket: Socket): void { |
| 423 | // Do nothing. Our implementation does not depend on socket class. |
| 424 | } |
| 425 | |
| 426 | addTrailers( |
| 427 | _headers: OutgoingHttpHeaders | ReadonlyArray<[string, string]> |
| 428 | ): void { |
| 429 | // We don't support trailers. |
| 430 | throw new ERR_METHOD_NOT_IMPLEMENTED('addTrailers'); |
| 431 | } |
| 432 | |
| 433 | abort(error?: Error | null): void { |
| 434 | this.destroyed = true; |
| 435 | this.#resetTimers({ finished: true }); |
| 436 | if (this.#incomingMessage) { |
| 437 | this.#incomingMessage.destroyed = true; |
| 438 | } |
| 439 | this.#abortController.abort(); |
| 440 | if (error) { |
| 441 | this.emit('error', error); |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | override _write( |
| 446 | chunk: Buffer, |
| 447 | _encoding: BufferEncoding, |
| 448 | callback: VoidFunction |
| 449 | ): boolean { |
| 450 | this.#body.push(chunk); |
| 451 | callback(); |
| 452 | return true; |
| 453 | } |
| 454 | |
| 455 | setNoDelay(noDelay?: boolean): void { |
| 456 | validateBoolean(noDelay, 'noDelay'); |
| 457 | // Not implemented |
| 458 | } |
| 459 | |
| 460 | setSocketKeepAlive(enable?: boolean, initialDelay?: number): void { |
| 461 | validateBoolean(enable, 'enable'); |
| 462 | validateNumber(initialDelay, 'initialDelay'); |
| 463 | // Not implemented |
| 464 | } |
| 465 | |
| 466 | clearTimeout(cb?: VoidFunction): void { |
| 467 | this.setTimeout(0, cb); |
| 468 | } |
| 469 | |
| 470 | setTimeout(msecs: number, callback?: VoidFunction): this { |
| 471 | if (this.#timer) { |
| 472 | clearTimeout(this.#timer); |
| 473 | this.#timer = null; |
| 474 | } |
| 475 | |
| 476 | this.timeout = getTimerDuration(msecs, 'msecs'); |
| 477 | this.#resetTimers({ finished: false }); |
| 478 | |
| 479 | if (callback) this.once('timeout', callback); |
| 480 | |
| 481 | return this; |
| 482 | } |
| 483 | |
| 484 | override write( |
| 485 | chunk: string | Buffer | Uint8Array, |
| 486 | encoding?: BufferEncoding | WriteCallback | null, |
| 487 | callback?: WriteCallback |
| 488 | ): boolean { |
| 489 | // Capture the data for the request body |
| 490 | if (this.method !== 'GET' && this.method !== 'HEAD' && chunk) { |
| 491 | if (typeof chunk === 'string') { |
| 492 | this.#body.push( |
| 493 | Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8') |
| 494 | ); |
| 495 | } else { |
| 496 | this.#body.push(chunk); |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | // Call the parent write method |
| 501 | return super.write(chunk, encoding, callback); |
| 502 | } |
| 503 | |
| 504 | override end( |
| 505 | data?: Buffer | string | VoidFunction, |
| 506 | encoding?: BufferEncoding | VoidFunction, |
| 507 | callback?: VoidFunction |
| 508 | ): this { |
| 509 | this._ended = true; |
| 510 | |
| 511 | if (typeof data === 'function') { |
| 512 | callback = data as VoidFunction; |
| 513 | data = undefined; |
| 514 | } |
| 515 | |
| 516 | // Don't duplicate data here - let the parent's end() call write() which will handle it |
| 517 | Writable.prototype.end.call( |
| 518 | this, |
| 519 | data, |
| 520 | encoding as BufferEncoding, |
| 521 | callback |
| 522 | ); |
| 523 | return this; |
| 524 | } |
| 525 | |
| 526 | #resetTimers({ finished }: { finished: boolean }): void { |
| 527 | if (finished) { |
| 528 | clearTimeout(this.#timer as number); |
| 529 | this.#timer = null; |
| 530 | } else if (this.timeout) { |
| 531 | if (this.#timer) { |
| 532 | clearTimeout(this.#timer); |
| 533 | } |
| 534 | this.#timer = setTimeout(() => { |
| 535 | this.emit('timeout'); |
| 536 | this.#incomingMessage?.emit('timeout'); |
| 537 | this.#abortController.abort(); |
| 538 | }, this.timeout) as unknown as number; |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | override _implicitHeader(): void { |
| 543 | if (this._header) { |
| 544 | throw new ERR_HTTP_HEADERS_SENT('render'); |
| 545 | } |
| 546 | this._storeHeader( |
| 547 | this.method + ' ' + this.path + ' HTTP/1.1\r\n', |
| 548 | this[kOutHeaders] as OutgoingHttpHeaders |
| 549 | ); |
| 550 | } |
| 551 | } |