-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: migrate API client and codebase to native fetch (Part 1) #10723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,7 @@ | ||
| import { AbortSignal } from "abort-controller"; | ||
| import { URL, URLSearchParams } from "url"; | ||
| import { Readable } from "stream"; | ||
| import { ProxyAgent } from "proxy-agent"; | ||
| import { ProxyAgent, request } from "undici"; | ||
| import * as retry from "retry"; | ||
| import AbortController from "abort-controller"; | ||
| import fetch, { HeadersInit, Response, RequestInit, Headers } from "node-fetch"; | ||
| import * as http from "http"; | ||
| import * as https from "https"; | ||
| import util from "util"; | ||
|
|
@@ -18,8 +15,8 @@ | |
|
|
||
| // Using import would require resolveJsonModule, which seems to break the | ||
| // build/output format. | ||
| const pkg = require("../package.json"); | ||
|
Check warning on line 18 in src/apiv2.ts
|
||
| const CLI_VERSION: string = pkg.version; | ||
|
Check warning on line 19 in src/apiv2.ts
|
||
|
|
||
| export const standardHeaders: () => Record<string, string> = () => { | ||
| const agent = detectAIAgent(); | ||
|
|
@@ -38,7 +35,6 @@ | |
|
|
||
| // Header for specifying a quota project. See https://cloud.google.com/apis/docs/system-parameters#project-header | ||
| export const GOOG_USER_PROJECT_HEADER = "x-goog-user-project"; | ||
| const GOOGLE_CLOUD_QUOTA_PROJECT = process.env.GOOGLE_CLOUD_QUOTA_PROJECT; | ||
| export const CLI_OAUTH_PROJECT_NUMBER = "563584335869"; | ||
|
|
||
| export type HttpMethod = | ||
|
|
@@ -136,7 +132,7 @@ | |
|
|
||
| /** | ||
| * Gets a singleton access token | ||
| * @returns An access token | ||
| */ | ||
| export async function getAccessToken(): Promise<string> { | ||
| const valid = auth.haveValidTokens(refreshToken, []); | ||
|
|
@@ -150,13 +146,16 @@ | |
| } | ||
|
|
||
| function proxyURIFromEnv(): string | undefined { | ||
| return ( | ||
| const uri = | ||
| process.env.HTTPS_PROXY || | ||
| process.env.https_proxy || | ||
| process.env.HTTP_PROXY || | ||
| process.env.http_proxy || | ||
| undefined | ||
| ); | ||
| undefined; | ||
| if (uri === "undefined" || uri === "null") { | ||
| return undefined; | ||
| } | ||
| return uri; | ||
| } | ||
|
|
||
| // Some networks (and recent Node.js security releases) interact badly with | ||
|
|
@@ -168,7 +167,7 @@ | |
| // https://github.com/node-fetch/node-fetch/issues/1767. | ||
| const httpAgentNoKeepAlive = new http.Agent({ keepAlive: false }); | ||
| const httpsAgentNoKeepAlive = new https.Agent({ keepAlive: false }); | ||
| export function noKeepAliveAgent(parsedURL: URL): http.Agent | https.Agent { | ||
| return parsedURL.protocol === "https:" ? httpsAgentNoKeepAlive : httpAgentNoKeepAlive; | ||
| } | ||
|
|
||
|
|
@@ -198,6 +197,12 @@ | |
| auth?: boolean; | ||
| }; | ||
|
|
||
| interface FetchOptions extends RequestInit { | ||
| dispatcher?: ProxyAgent; | ||
| duplex?: "half"; | ||
| agent?: http.Agent | https.Agent | ((parsedUrl: URL) => http.Agent | https.Agent); | ||
| } | ||
|
|
||
| export class Client { | ||
| constructor(private opts: ClientOptions) { | ||
| if (this.opts.auth === undefined) { | ||
|
|
@@ -274,7 +279,7 @@ | |
| * Makes a request as specified by the options. | ||
| * By default, this will: | ||
| * - use content-type: application/json | ||
| * - assume the HTTP GET method | ||
| * | ||
| * @example | ||
| * const res = apiv2.request<ResourceType>({ | ||
|
|
@@ -313,8 +318,8 @@ | |
| } | ||
| try { | ||
| return await this.doRequest<ReqT, ResT>(internalReqOptions); | ||
| } catch (thrown: any) { | ||
| const originalErrorMessage = thrown.original?.message || thrown.message || ""; | ||
|
Check warning on line 322 in src/apiv2.ts
|
||
| if (originalErrorMessage.includes(CLI_OAUTH_PROJECT_NUMBER)) { | ||
| // Error messages mentioning the CLI's OAuth project number should not be shared with end users, | ||
| // since they will never be actionable for them. If we do display them, support gets a bunch of tickets asking for quota | ||
|
|
@@ -357,10 +362,10 @@ | |
| } | ||
| if ( | ||
| !reqOptions.ignoreQuotaProject && | ||
| GOOGLE_CLOUD_QUOTA_PROJECT && | ||
| GOOGLE_CLOUD_QUOTA_PROJECT !== "" | ||
| process.env.GOOGLE_CLOUD_QUOTA_PROJECT && | ||
| process.env.GOOGLE_CLOUD_QUOTA_PROJECT !== "" | ||
| ) { | ||
| reqOptions.headers.set(GOOG_USER_PROJECT_HEADER, GOOGLE_CLOUD_QUOTA_PROJECT); | ||
| reqOptions.headers.set(GOOG_USER_PROJECT_HEADER, process.env.GOOGLE_CLOUD_QUOTA_PROJECT); | ||
| } | ||
| return reqOptions; | ||
| } | ||
|
|
@@ -409,24 +414,19 @@ | |
| } | ||
| } | ||
|
|
||
| const fetchOptions: RequestInit = { | ||
| const fetchOptions: FetchOptions = { | ||
| headers: options.headers, | ||
| method: options.method, | ||
| redirect: options.redirect, | ||
| compress: options.compress, | ||
| }; | ||
|
|
||
| if (proxyURIFromEnv()) { | ||
| fetchOptions.agent = new ProxyAgent(); | ||
| const proxyURI = proxyURIFromEnv(); | ||
| if (proxyURI) { | ||
| fetchOptions.dispatcher = new ProxyAgent({ uri: proxyURI }); | ||
| } | ||
|
|
||
| if (options.signal) { | ||
| const signal = options.signal as any; | ||
| signal.reason = ""; | ||
| signal.throwIfAborted = () => { | ||
| throw new FirebaseError("Aborted"); | ||
| }; | ||
| fetchOptions.signal = signal; | ||
| fetchOptions.signal = options.signal; | ||
| } | ||
|
|
||
| let reqTimeout: NodeJS.Timeout | undefined; | ||
|
|
@@ -435,12 +435,7 @@ | |
| reqTimeout = setTimeout(() => { | ||
| controller.abort(); | ||
| }, options.timeout); | ||
| const signal = controller.signal as any; | ||
| signal.reason = ""; | ||
| signal.throwIfAborted = () => { | ||
| throw new FirebaseError("Aborted"); | ||
| }; | ||
| fetchOptions.signal = signal; | ||
| fetchOptions.signal = controller.signal; | ||
| } | ||
|
|
||
| // A request can only be safely retried if its body can be sent again. | ||
|
|
@@ -449,11 +444,12 @@ | |
| // Raw streams cannot be replayed and therefore disable the keep-alive retry. | ||
| let bodyReplayable = true; | ||
| if (typeof options.body === "string" || Buffer.isBuffer(options.body)) { | ||
| fetchOptions.body = options.body; | ||
| fetchOptions.body = options.body as any; | ||
| } else if (options.body instanceof FormData) { | ||
| fetchOptions.body = options.body.getBuffer(); | ||
| fetchOptions.body = options.body.getBuffer() as any; | ||
| } else if (isStream(options.body)) { | ||
| fetchOptions.body = options.body; | ||
| fetchOptions.body = options.body as any; | ||
| fetchOptions.duplex = "half"; | ||
| bodyReplayable = false; | ||
| } else if (options.body !== undefined) { | ||
| fetchOptions.body = JSON.stringify(options.body); | ||
|
|
@@ -493,9 +489,38 @@ | |
| } | ||
| this.logRequest(options); | ||
| try { | ||
| res = await fetch(fetchURL, fetchOptions); | ||
| if (options.compress === false) { | ||
| const undiciOptions: any = { | ||
| method: fetchOptions.method, | ||
| headers: {}, | ||
| decompress: false, | ||
| }; | ||
| if (fetchOptions.dispatcher) { | ||
| undiciOptions.dispatcher = fetchOptions.dispatcher; | ||
| } | ||
| if (fetchOptions.signal) { | ||
| undiciOptions.signal = fetchOptions.signal; | ||
| } | ||
| if (fetchOptions.body) { | ||
| undiciOptions.body = fetchOptions.body; | ||
| } | ||
| if (fetchOptions.headers) { | ||
| for (const [key, value] of (fetchOptions.headers as any).entries()) { | ||
| undiciOptions.headers[key] = value; | ||
| } | ||
| } | ||
| const undiciRes = await request(fetchURL, undiciOptions); | ||
| res = new UndiciResponseCompat(undiciRes) as any; | ||
| } else { | ||
| res = await fetch(fetchURL, fetchOptions); | ||
| } | ||
| } catch (thrown: any) { | ||
| const err = thrown instanceof Error ? thrown : new Error(thrown); | ||
| const err = | ||
| thrown && typeof thrown === "object" && thrown.cause instanceof Error | ||
| ? thrown.cause | ||
| : thrown instanceof Error | ||
| ? thrown | ||
| : new Error(thrown); | ||
| logger.debug( | ||
| `*** [apiv2] error from fetch(${fetchURL}, ${JSON.stringify(fetchOptions)}): ${err}`, | ||
| ); | ||
|
|
@@ -532,7 +557,17 @@ | |
| } else if (options.responseType === "xml") { | ||
| body = (await res.text()) as unknown as ResT; | ||
| } else if (options.responseType === "stream") { | ||
| body = res.body as unknown as ResT; | ||
| if (res.body) { | ||
| if (typeof (res.body as any).getReader === "function") { | ||
| body = Readable.fromWeb(res.body as any) as unknown as ResT; | ||
| } else { | ||
| body = res.body as unknown as ResT; | ||
| } | ||
| } else { | ||
| const emptyStream = new Readable(); | ||
| emptyStream.push(null); | ||
| body = emptyStream as unknown as ResT; | ||
| } | ||
| } else { | ||
| throw new FirebaseError(`Unable to interpret response. Please set responseType.`, { | ||
| exit: 2, | ||
|
|
@@ -650,6 +685,37 @@ | |
| return u.protocol === "http:"; | ||
| } | ||
|
|
||
| class UndiciResponseCompat { | ||
| readonly status: number; | ||
| readonly headers: Headers; | ||
| readonly body: any; | ||
| readonly ok: boolean; | ||
|
|
||
| constructor(private undiciRes: any) { | ||
| this.status = undiciRes.statusCode; | ||
| this.ok = undiciRes.statusCode >= 200 && undiciRes.statusCode < 300; | ||
| this.headers = new Headers(); | ||
| for (const [key, value] of Object.entries(undiciRes.headers)) { | ||
| if (Array.isArray(value)) { | ||
| for (const v of value) { | ||
| this.headers.append(key, v); | ||
| } | ||
| } else if (value !== undefined) { | ||
| this.headers.set(key, String(value)); | ||
| } | ||
| } | ||
| this.body = undiciRes.body; | ||
| } | ||
|
|
||
| async text(): Promise<string> { | ||
| if (!this.body) { | ||
| return ""; | ||
| } | ||
| const { streamToString } = require("./utils"); | ||
| return streamToString(this.body); | ||
| } | ||
|
Comment on lines
+710
to
+716
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a defensive check to ensure async text(): Promise<string> {
if (!this.body) {
return "";
}
const { streamToString } = require("./utils");
return streamToString(this.body);
} |
||
| } | ||
|
|
||
| function bodyToString(body: unknown): string { | ||
| if (isStream(body)) { | ||
| // Don't attempt to read any stream type, in case the caller needs it. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid using
anyas an escape hatch for theagentproperty. SincenoKeepAliveAgentis a function returninghttp.Agent | https.Agent, we can type this property more precisely to adhere to the repository style guide.References
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)