Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 149 additions & 108 deletions npm-shrinkwrap.json

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@
"triple-beam": "^1.3.0",
"universal-analytics": "^0.5.3",
"update-notifier-cjs": "^5.1.6",
"undici": "^6.19.0",
"winston": "^3.0.0",
"winston-transport": "^4.4.0",
"ws": "^7.5.10",
Expand Down Expand Up @@ -270,9 +271,6 @@
"ajv-formats": "3.0.1",
"ajv": "^8.17.1"
},
"node-fetch": {
"whatwg-url": "^14.0.0"
},
"hono": "^4.11.4",
"@tootallnate/once": "^3.0.1",
"protobufjs": "^7.4.0",
Expand Down
132 changes: 99 additions & 33 deletions src/apiv2.ts
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";
Expand All @@ -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

View workflow job for this annotation

GitHub Actions / lint (24)

Require statement not part of import statement

Check warning on line 18 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
const CLI_VERSION: string = pkg.version;

Check warning on line 19 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .version on an `any` value

Check warning on line 19 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value

export const standardHeaders: () => Record<string, string> = () => {
const agent = detectAIAgent();
Expand All @@ -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 =
Expand Down Expand Up @@ -136,7 +132,7 @@

/**
* Gets a singleton access token
* @returns An access token

Check warning on line 135 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid JSDoc tag (preference). Replace "returns" JSDoc tag with "return"
*/
export async function getAccessToken(): Promise<string> {
const valid = auth.haveValidTokens(refreshToken, []);
Expand All @@ -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
Expand All @@ -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 {

Check warning on line 170 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing JSDoc comment
return parsedURL.protocol === "https:" ? httpsAgentNoKeepAlive : httpAgentNoKeepAlive;
}

Expand Down Expand Up @@ -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);
}
Comment on lines +200 to +204

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid using any as an escape hatch for the agent property. Since noKeepAliveAgent is a function returning http.Agent | https.Agent, we can type this property more precisely to adhere to the repository style guide.

Suggested change
interface FetchOptions extends RequestInit {
dispatcher?: ProxyAgent;
duplex?: "half";
agent?: any;
}
interface FetchOptions extends RequestInit {
dispatcher?: ProxyAgent;
duplex?: "half";
agent?: http.Agent | https.Agent | ((parsedURL: URL) => http.Agent | https.Agent);
}
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)


export class Client {
constructor(private opts: ClientOptions) {
if (this.opts.auth === undefined) {
Expand Down Expand Up @@ -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

Check warning on line 282 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Expected only 0 line after block description
*
* @example
* const res = apiv2.request<ResourceType>({
Expand Down Expand Up @@ -313,8 +318,8 @@
}
try {
return await this.doRequest<ReqT, ResT>(internalReqOptions);
} catch (thrown: any) {

Check warning on line 321 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
const originalErrorMessage = thrown.original?.message || thrown.message || "";

Check warning on line 322 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .original on an `any` value

Check warning on line 322 in src/apiv2.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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}`,
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a defensive check to ensure this.body is defined before passing it to streamToString. If this.body is null or undefined (e.g., for a 204 No Content response), calling streamToString(this.body) would throw a TypeError.

  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.
Expand Down
1 change: 0 additions & 1 deletion src/apphosting/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { DeepOmit } from "../metaprogramming";
import { webApps } from "./app";
import { GitRepositoryLink } from "../gcp/devConnect";
import * as ora from "ora";
import fetch from "node-fetch";
import { orchestrateRollout } from "./rollout";
import * as fuzzy from "fuzzy";
import { isEnabled } from "../experiments";
Expand Down
6 changes: 3 additions & 3 deletions src/database/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import * as StreamObject from "stream-json/streamers/StreamObject";

import { URL } from "url";
import { Client, ClientResponse } from "../apiv2";
import { FetchError } from "node-fetch";
import { FirebaseError } from "../error";
import { pLimit, Limit } from "../utils";

Expand Down Expand Up @@ -240,8 +239,9 @@ export default class DatabaseImporter {
} catch (err: any) {
const isTimeoutErr =
err instanceof FirebaseError &&
err.original instanceof FetchError &&
err.original.code === "ETIMEDOUT";
(err.original?.name === "AbortError" ||
(err.original as any)?.code === "ETIMEDOUT" ||
(err.original as any)?.cause?.code === "ETIMEDOUT");
if (isTimeoutErr) {
// RTDB connection timeouts are transient and can be retried
await new Promise((res) => setTimeout(res, this.nonFatalRetryTimeout));
Expand Down
4 changes: 1 addition & 3 deletions src/dataconnect/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
* Webhook send API used to notify VSCode of states within
*/

import fetch from "node-fetch";
import { logger } from "../logger";
import { AbortSignal } from "node-fetch/externals";

export enum VSCODE_MESSAGE {
EMULATORS_STARTED = "EMULATORS_STARTED",
Expand Down Expand Up @@ -33,7 +31,7 @@ export async function sendVSCodeMessage(body: WebhookBody) {
"x-mantle-admin": "all",
},
body: jsonBody,
signal: AbortSignal.timeout(3000) as unknown as AbortSignal, // necessary due to https://github.com/node-fetch/node-fetch/issues/1652
signal: AbortSignal.timeout(3000),
});
} catch (e) {
logger.debug(
Expand Down
5 changes: 3 additions & 2 deletions src/deploy/functions/runtimes/discovery/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import fetch, { Response } from "node-fetch";
import * as fs from "fs";
import * as path from "path";
import * as yaml from "yaml";
Expand Down Expand Up @@ -97,9 +96,11 @@ export async function detectFromPort(
res = await Promise.race([fetch(url), timedOut]);
break;
} catch (err: any) {
const realErr = err?.cause || err;
if (
err?.name === "FetchError" ||
["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(err?.code)
realErr?.name === "FetchError" ||
["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(realErr?.code)
) {
continue;
}
Expand Down
1 change: 0 additions & 1 deletion src/deploy/functions/runtimes/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import * as path from "path";
import * as portfinder from "portfinder";
import * as semver from "semver";
import * as spawn from "cross-spawn";
import fetch from "node-fetch";
import { ChildProcess } from "child_process";

import { FirebaseError } from "../../../../error";
Expand Down
1 change: 0 additions & 1 deletion src/deploy/functions/runtimes/python/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as fs from "fs";
import * as path from "path";
import fetch from "node-fetch";
import { promisify } from "util";

import * as portfinder from "portfinder";
Expand Down
3 changes: 1 addition & 2 deletions src/deploy/hosting/uploader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { size } from "lodash";
import AbortController from "abort-controller";
import * as clc from "colorette";
import * as crypto from "crypto";
import * as fs from "fs";
Expand Down Expand Up @@ -250,7 +249,7 @@ export class Uploader {
logger.debug(
`[hosting][upload] ${this.hashMap[toUpload]} (${toUpload}) HTTP ERROR ${
res.status
}: headers=${JSON.stringify(res.response.headers.raw())} ${errorMessage}`,
}: headers=${JSON.stringify(Object.fromEntries((res.response.headers as any).entries()))} ${errorMessage}`,
);
throw new Error(`Unexpected error while uploading file: ${errorMessage}`);
}
Expand Down
4 changes: 3 additions & 1 deletion src/downloadUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export async function downloadToTmp(remoteUrl: string, auth = false): Promise<st
resolveOnHTTPError: true,
});
if (res.status !== 200) {
throw new FirebaseError(`download failed, status ${res.status}: ${await res.response.text()}`, {
const { streamToString } = require("./utils");
const errorText = await streamToString(res.body);
throw new FirebaseError(`download failed, status ${res.status}: ${errorText}`, {
status: res.status,
});
}
Expand Down
22 changes: 16 additions & 6 deletions src/emulator/auth/operations.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { URLSearchParams } from "url";
import { decode as decodeJwt, sign as signJwt, JwtHeader } from "jsonwebtoken";
import * as express from "express";
import fetch from "node-fetch";
import AbortController from "abort-controller";
import { ExegesisContext } from "exegesis-express";
import {
toUnixTimestamp,
Expand Down Expand Up @@ -3262,10 +3260,22 @@ async function fetchBlockingFunction(
let text: string;
try {
const signal = controller.signal as any;
signal.reason = "";
signal.throwIfAborted = () => {
throw new FirebaseError("Aborted");
};
try {
if (!("reason" in signal)) {
signal.reason = "";
}
} catch (e) {
// Ignore if read-only
}
try {
if (!("throwIfAborted" in signal)) {
signal.throwIfAborted = () => {
throw new FirebaseError("Aborted");
};
}
} catch (e) {
// Ignore if read-only
}
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand Down
Loading
Loading