Skip to content

Commit 4d2c3cd

Browse files
committed
feat: migrate API client and codebase to native fetch
1 parent 7781a66 commit 4d2c3cd

18 files changed

Lines changed: 321 additions & 199 deletions

File tree

npm-shrinkwrap.json

Lines changed: 149 additions & 108 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"triple-beam": "^1.3.0",
171171
"universal-analytics": "^0.5.3",
172172
"update-notifier-cjs": "^5.1.6",
173+
"undici": "^6.19.0",
173174
"winston": "^3.0.0",
174175
"winston-transport": "^4.4.0",
175176
"ws": "^7.5.10",
@@ -270,9 +271,6 @@
270271
"ajv-formats": "3.0.1",
271272
"ajv": "^8.17.1"
272273
},
273-
"node-fetch": {
274-
"whatwg-url": "^14.0.0"
275-
},
276274
"hono": "^4.11.4",
277275
"@tootallnate/once": "^3.0.1",
278276
"protobufjs": "^7.4.0",

src/apiv2.ts

Lines changed: 96 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { AbortSignal } from "abort-controller";
21
import { URL, URLSearchParams } from "url";
32
import { Readable } from "stream";
4-
import { ProxyAgent } from "proxy-agent";
3+
import { ProxyAgent, request } from "undici";
54
import * as retry from "retry";
6-
import AbortController from "abort-controller";
7-
import fetch, { HeadersInit, Response, RequestInit, Headers } from "node-fetch";
85
import * as http from "http";
96
import * as https from "https";
107
import util from "util";
@@ -38,7 +35,6 @@ const GOOG_QUOTA_USER_HEADER = "x-goog-quota-user";
3835

3936
// Header for specifying a quota project. See https://cloud.google.com/apis/docs/system-parameters#project-header
4037
export const GOOG_USER_PROJECT_HEADER = "x-goog-user-project";
41-
const GOOGLE_CLOUD_QUOTA_PROJECT = process.env.GOOGLE_CLOUD_QUOTA_PROJECT;
4238
export const CLI_OAUTH_PROJECT_NUMBER = "563584335869";
4339

4440
export type HttpMethod =
@@ -150,13 +146,16 @@ export async function getAccessToken(): Promise<string> {
150146
}
151147

152148
function proxyURIFromEnv(): string | undefined {
153-
return (
149+
const uri =
154150
process.env.HTTPS_PROXY ||
155151
process.env.https_proxy ||
156152
process.env.HTTP_PROXY ||
157153
process.env.http_proxy ||
158-
undefined
159-
);
154+
undefined;
155+
if (uri === "undefined" || uri === "null") {
156+
return undefined;
157+
}
158+
return uri;
160159
}
161160

162161
// Some networks (and recent Node.js security releases) interact badly with
@@ -198,6 +197,12 @@ export type ClientOptions = {
198197
auth?: boolean;
199198
};
200199

200+
interface FetchOptions extends RequestInit {
201+
dispatcher?: ProxyAgent;
202+
duplex?: "half";
203+
agent?: any;
204+
}
205+
201206
export class Client {
202207
constructor(private opts: ClientOptions) {
203208
if (this.opts.auth === undefined) {
@@ -357,10 +362,10 @@ export class Client {
357362
}
358363
if (
359364
!reqOptions.ignoreQuotaProject &&
360-
GOOGLE_CLOUD_QUOTA_PROJECT &&
361-
GOOGLE_CLOUD_QUOTA_PROJECT !== ""
365+
process.env.GOOGLE_CLOUD_QUOTA_PROJECT &&
366+
process.env.GOOGLE_CLOUD_QUOTA_PROJECT !== ""
362367
) {
363-
reqOptions.headers.set(GOOG_USER_PROJECT_HEADER, GOOGLE_CLOUD_QUOTA_PROJECT);
368+
reqOptions.headers.set(GOOG_USER_PROJECT_HEADER, process.env.GOOGLE_CLOUD_QUOTA_PROJECT);
364369
}
365370
return reqOptions;
366371
}
@@ -409,24 +414,19 @@ export class Client {
409414
}
410415
}
411416

412-
const fetchOptions: RequestInit = {
417+
const fetchOptions: FetchOptions = {
413418
headers: options.headers,
414419
method: options.method,
415420
redirect: options.redirect,
416-
compress: options.compress,
417421
};
418422

419-
if (proxyURIFromEnv()) {
420-
fetchOptions.agent = new ProxyAgent();
423+
const proxyURI = proxyURIFromEnv();
424+
if (proxyURI) {
425+
fetchOptions.dispatcher = new ProxyAgent({ uri: proxyURI });
421426
}
422427

423428
if (options.signal) {
424-
const signal = options.signal as any;
425-
signal.reason = "";
426-
signal.throwIfAborted = () => {
427-
throw new FirebaseError("Aborted");
428-
};
429-
fetchOptions.signal = signal;
429+
fetchOptions.signal = options.signal;
430430
}
431431

432432
let reqTimeout: NodeJS.Timeout | undefined;
@@ -435,12 +435,7 @@ export class Client {
435435
reqTimeout = setTimeout(() => {
436436
controller.abort();
437437
}, options.timeout);
438-
const signal = controller.signal as any;
439-
signal.reason = "";
440-
signal.throwIfAborted = () => {
441-
throw new FirebaseError("Aborted");
442-
};
443-
fetchOptions.signal = signal;
438+
fetchOptions.signal = controller.signal;
444439
}
445440

446441
// A request can only be safely retried if its body can be sent again.
@@ -449,11 +444,12 @@ export class Client {
449444
// Raw streams cannot be replayed and therefore disable the keep-alive retry.
450445
let bodyReplayable = true;
451446
if (typeof options.body === "string" || Buffer.isBuffer(options.body)) {
452-
fetchOptions.body = options.body;
447+
fetchOptions.body = options.body as any;
453448
} else if (options.body instanceof FormData) {
454-
fetchOptions.body = options.body.getBuffer();
449+
fetchOptions.body = options.body.getBuffer() as any;
455450
} else if (isStream(options.body)) {
456-
fetchOptions.body = options.body;
451+
fetchOptions.body = options.body as any;
452+
fetchOptions.duplex = "half";
457453
bodyReplayable = false;
458454
} else if (options.body !== undefined) {
459455
fetchOptions.body = JSON.stringify(options.body);
@@ -493,9 +489,38 @@ export class Client {
493489
}
494490
this.logRequest(options);
495491
try {
496-
res = await fetch(fetchURL, fetchOptions);
492+
if (options.compress === false) {
493+
const undiciOptions: any = {
494+
method: fetchOptions.method,
495+
headers: {},
496+
decompress: false,
497+
};
498+
if (fetchOptions.dispatcher) {
499+
undiciOptions.dispatcher = fetchOptions.dispatcher;
500+
}
501+
if (fetchOptions.signal) {
502+
undiciOptions.signal = fetchOptions.signal;
503+
}
504+
if (fetchOptions.body) {
505+
undiciOptions.body = fetchOptions.body;
506+
}
507+
if (fetchOptions.headers) {
508+
for (const [key, value] of (fetchOptions.headers as any).entries()) {
509+
undiciOptions.headers[key] = value;
510+
}
511+
}
512+
const undiciRes = await request(fetchURL, undiciOptions);
513+
res = new UndiciResponseCompat(undiciRes) as any;
514+
} else {
515+
res = await fetch(fetchURL, fetchOptions);
516+
}
497517
} catch (thrown: any) {
498-
const err = thrown instanceof Error ? thrown : new Error(thrown);
518+
const err =
519+
thrown && typeof thrown === "object" && thrown.cause instanceof Error
520+
? thrown.cause
521+
: thrown instanceof Error
522+
? thrown
523+
: new Error(thrown);
499524
logger.debug(
500525
`*** [apiv2] error from fetch(${fetchURL}, ${JSON.stringify(fetchOptions)}): ${err}`,
501526
);
@@ -532,7 +557,17 @@ export class Client {
532557
} else if (options.responseType === "xml") {
533558
body = (await res.text()) as unknown as ResT;
534559
} else if (options.responseType === "stream") {
535-
body = res.body as unknown as ResT;
560+
if (res.body) {
561+
if (typeof (res.body as any).getReader === "function") {
562+
body = Readable.fromWeb(res.body as any) as unknown as ResT;
563+
} else {
564+
body = res.body as unknown as ResT;
565+
}
566+
} else {
567+
const emptyStream = new Readable();
568+
emptyStream.push(null);
569+
body = emptyStream as unknown as ResT;
570+
}
536571
} else {
537572
throw new FirebaseError(`Unable to interpret response. Please set responseType.`, {
538573
exit: 2,
@@ -650,6 +685,34 @@ function isLocalInsecureRequest(urlPrefix: string): boolean {
650685
return u.protocol === "http:";
651686
}
652687

688+
class UndiciResponseCompat {
689+
readonly status: number;
690+
readonly headers: Headers;
691+
readonly body: any;
692+
readonly ok: boolean;
693+
694+
constructor(private undiciRes: any) {
695+
this.status = undiciRes.statusCode;
696+
this.ok = undiciRes.statusCode >= 200 && undiciRes.statusCode < 300;
697+
this.headers = new Headers();
698+
for (const [key, value] of Object.entries(undiciRes.headers)) {
699+
if (Array.isArray(value)) {
700+
for (const v of value) {
701+
this.headers.append(key, v);
702+
}
703+
} else if (value !== undefined) {
704+
this.headers.set(key, String(value));
705+
}
706+
}
707+
this.body = undiciRes.body;
708+
}
709+
710+
async text(): Promise<string> {
711+
const { streamToString } = require("./utils");
712+
return streamToString(this.body);
713+
}
714+
}
715+
653716
function bodyToString(body: unknown): string {
654717
if (isStream(body)) {
655718
// Don't attempt to read any stream type, in case the caller needs it.

src/apphosting/backend.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import { DeepOmit } from "../metaprogramming";
2525
import { webApps } from "./app";
2626
import { GitRepositoryLink } from "../gcp/devConnect";
2727
import * as ora from "ora";
28-
import fetch from "node-fetch";
2928
import { orchestrateRollout } from "./rollout";
3029
import * as fuzzy from "fuzzy";
3130
import { isEnabled } from "../experiments";

src/database/import.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import * as StreamObject from "stream-json/streamers/StreamObject";
66

77
import { URL } from "url";
88
import { Client, ClientResponse } from "../apiv2";
9-
import { FetchError } from "node-fetch";
109
import { FirebaseError } from "../error";
1110
import { pLimit, Limit } from "../utils";
1211

@@ -240,8 +239,9 @@ export default class DatabaseImporter {
240239
} catch (err: any) {
241240
const isTimeoutErr =
242241
err instanceof FirebaseError &&
243-
err.original instanceof FetchError &&
244-
err.original.code === "ETIMEDOUT";
242+
(err.original?.name === "AbortError" ||
243+
(err.original as any)?.code === "ETIMEDOUT" ||
244+
(err.original as any)?.cause?.code === "ETIMEDOUT");
245245
if (isTimeoutErr) {
246246
// RTDB connection timeouts are transient and can be retried
247247
await new Promise((res) => setTimeout(res, this.nonFatalRetryTimeout));

src/dataconnect/webhook.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
* Webhook send API used to notify VSCode of states within
33
*/
44

5-
import fetch from "node-fetch";
65
import { logger } from "../logger";
7-
import { AbortSignal } from "node-fetch/externals";
86

97
export enum VSCODE_MESSAGE {
108
EMULATORS_STARTED = "EMULATORS_STARTED",
@@ -33,7 +31,7 @@ export async function sendVSCodeMessage(body: WebhookBody) {
3331
"x-mantle-admin": "all",
3432
},
3533
body: jsonBody,
36-
signal: AbortSignal.timeout(3000) as unknown as AbortSignal, // necessary due to https://github.com/node-fetch/node-fetch/issues/1652
34+
signal: AbortSignal.timeout(3000),
3735
});
3836
} catch (e) {
3937
logger.debug(

src/deploy/functions/runtimes/discovery/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import fetch, { Response } from "node-fetch";
21
import * as fs from "fs";
32
import * as path from "path";
43
import * as yaml from "yaml";
@@ -97,9 +96,11 @@ export async function detectFromPort(
9796
res = await Promise.race([fetch(url), timedOut]);
9897
break;
9998
} catch (err: any) {
99+
const realErr = err?.cause || err;
100100
if (
101101
err?.name === "FetchError" ||
102-
["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(err?.code)
102+
realErr?.name === "FetchError" ||
103+
["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(realErr?.code)
103104
) {
104105
continue;
105106
}

src/deploy/functions/runtimes/node/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import * as path from "path";
44
import * as portfinder from "portfinder";
55
import * as semver from "semver";
66
import * as spawn from "cross-spawn";
7-
import fetch from "node-fetch";
87
import { ChildProcess } from "child_process";
98

109
import { FirebaseError } from "../../../../error";

src/deploy/functions/runtimes/python/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import * as fs from "fs";
22
import * as path from "path";
3-
import fetch from "node-fetch";
43
import { promisify } from "util";
54

65
import * as portfinder from "portfinder";

src/deploy/hosting/uploader.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { size } from "lodash";
2-
import AbortController from "abort-controller";
32
import * as clc from "colorette";
43
import * as crypto from "crypto";
54
import * as fs from "fs";
@@ -250,7 +249,7 @@ export class Uploader {
250249
logger.debug(
251250
`[hosting][upload] ${this.hashMap[toUpload]} (${toUpload}) HTTP ERROR ${
252251
res.status
253-
}: headers=${JSON.stringify(res.response.headers.raw())} ${errorMessage}`,
252+
}: headers=${JSON.stringify(Object.fromEntries((res.response.headers as any).entries()))} ${errorMessage}`,
254253
);
255254
throw new Error(`Unexpected error while uploading file: ${errorMessage}`);
256255
}

0 commit comments

Comments
 (0)