Skip to content
File

Blob: src/node/internal/internal_http_client.ts

typescript552 lines
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 
6import { _checkIsHttpToken as checkIsHttpToken } from 'node-internal:internal_http';
7import {
8 kOutHeaders,
9 kUniqueHeaders,
10 parseUniqueHeadersOption,
11} from 'node-internal:internal_http_outgoing';
12import { Buffer } from 'node-internal:internal_buffer';
13import { urlToHttpOptions, isURL } from 'node-internal:internal_url';
14import {
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';
24import {
25 validateInteger,
26 validateBoolean,
27 validateFunction,
28 validateString,
29 validateNumber,
30} from 'node-internal:validators';
31import { getTimerDuration } from 'node-internal:internal_net';
32import { addAbortSignal } from 'node-internal:streams_add_abort_signal';
33import { Writable } from 'node-internal:streams_writable';
34import type {
35 ClientRequest as _ClientRequest,
36 RequestOptions,
37 OutgoingHttpHeaders,
38} from 'node:http';
39import {
40 IncomingMessage,
41 setIncomingMessageFetchResponse,
42} from 'node-internal:internal_http_incoming';
43import { OutgoingMessage } from 'node-internal:internal_http_outgoing';
44import { Agent, globalAgent } from 'node-internal:internal_http_agent';
45import type { IncomingMessageCallback } from 'node-internal:internal_http_util';
46import type { Socket } from 'node:net';
47 
48const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
49 
50type WriteCallback = (err?: Error) => void;
51 
52function 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.
64export 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}