Skip to content

Commit 34ac13c

Browse files
authored
Merge branch 'main' into local_builds_angular_ssr
2 parents 2dfdbf7 + a0a3d67 commit 34ac13c

9 files changed

Lines changed: 288 additions & 32 deletions

CHANGELOG.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +0,0 @@
1-
- Upgrade `zod` to v4 and drop the deprecated `zod-to-json-schema` dependency in favor of zod v4's built-in `z.toJSONSchema()`.
2-
- Updated the Firebase Data Connect local toolkit to v3.4.14, which includes the following changes:
3-
- Fix linter warnings in generated Kotlin SDK files.

npm-shrinkwrap.json

Lines changed: 2 additions & 2 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "firebase-tools",
3-
"version": "15.22.1",
3+
"version": "15.22.2",
44
"description": "Command-Line Interface for Firebase",
55
"main": "./lib/index.js",
66
"mcpName": "io.github.firebase/firebase-mcp",

src/apiv2.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createServer, Server } from "http";
22
import { expect } from "chai";
33
import * as nock from "nock";
44
import AbortController from "abort-controller";
5+
import * as FormData from "form-data";
56
const proxySetup = require("proxy");
67

78
import { Client, CLI_OAUTH_PROJECT_NUMBER } from "./apiv2";
@@ -109,6 +110,79 @@ describe("apiv2", () => {
109110
expect(nock.isDone()).to.be.true;
110111
});
111112

113+
it("should retry without keep-alive after a premature close error", async () => {
114+
nock("https://example.com").get("/path/to/foo").once().replyWithError({
115+
message:
116+
"Invalid response body while trying to fetch https://example.com/path/to/foo: Premature close",
117+
code: "ERR_STREAM_PREMATURE_CLOSE",
118+
});
119+
nock("https://example.com").get("/path/to/foo").once().reply(200, { foo: "bar" });
120+
121+
const c = new Client({ urlPrefix: "https://example.com" });
122+
const r = await c.request({
123+
method: "GET",
124+
path: "/path/to/foo",
125+
retries: 1,
126+
retryMinTimeout: 10,
127+
retryMaxTimeout: 15,
128+
});
129+
expect(r.body).to.deep.equal({ foo: "bar" });
130+
expect(nock.isDone()).to.be.true;
131+
});
132+
133+
it("should give up if the connection keeps closing prematurely", async () => {
134+
nock("https://example.com").get("/path/to/foo").twice().replyWithError({
135+
message:
136+
"Invalid response body while trying to fetch https://example.com/path/to/foo: Premature close",
137+
code: "ERR_STREAM_PREMATURE_CLOSE",
138+
});
139+
140+
const c = new Client({ urlPrefix: "https://example.com" });
141+
const r = c.request({
142+
method: "GET",
143+
path: "/path/to/foo",
144+
retries: 1,
145+
retryMinTimeout: 10,
146+
retryMaxTimeout: 15,
147+
});
148+
await expect(r).to.eventually.be.rejectedWith(FirebaseError, /Failed to make request/);
149+
expect(nock.isDone()).to.be.true;
150+
});
151+
152+
it("should resend a multipart body when retrying after a premature close", async () => {
153+
const sentBodies: string[] = [];
154+
const capture = (b: unknown): boolean => {
155+
sentBodies.push(typeof b === "string" ? b : JSON.stringify(b));
156+
return true;
157+
};
158+
nock("https://example.com").post("/upload", capture).once().replyWithError({
159+
message:
160+
"Invalid response body while trying to fetch https://example.com/upload: Premature close",
161+
code: "ERR_STREAM_PREMATURE_CLOSE",
162+
});
163+
nock("https://example.com").post("/upload", capture).once().reply(200, { ok: true });
164+
165+
const form = new FormData();
166+
form.append("code", "secret-code-123");
167+
168+
const c = new Client({ urlPrefix: "https://example.com" });
169+
const r = await c.request({
170+
method: "POST",
171+
path: "/upload",
172+
body: form,
173+
headers: form.getHeaders(),
174+
retries: 1,
175+
retryMinTimeout: 10,
176+
retryMaxTimeout: 15,
177+
});
178+
expect(r.status).to.equal(200);
179+
// Both the original attempt and the retry must carry the full body.
180+
expect(sentBodies).to.have.length(2);
181+
expect(sentBodies[0]).to.contain("secret-code-123");
182+
expect(sentBodies[1]).to.contain("secret-code-123");
183+
expect(nock.isDone()).to.be.true;
184+
});
185+
112186
it("should not allow resolving on http error when streaming", async () => {
113187
const c = new Client({ urlPrefix: "https://example.com" });
114188
const r = c.request<unknown, NodeJS.ReadableStream>({

src/apiv2.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { ProxyAgent } from "proxy-agent";
55
import * as retry from "retry";
66
import AbortController from "abort-controller";
77
import fetch, { HeadersInit, Response, RequestInit, Headers } from "node-fetch";
8+
import * as http from "http";
9+
import * as https from "https";
810
import util from "util";
911

1012
import * as auth from "./auth";
@@ -157,6 +159,39 @@ function proxyURIFromEnv(): string | undefined {
157159
);
158160
}
159161

162+
// Some networks (and recent Node.js security releases) interact badly with
163+
// node-fetch's keep-alive handling: a reused/closed keep-alive socket surfaces
164+
// as an "Invalid response body ...: Premature close" error while the response
165+
// body is being read, even though the response is otherwise valid. Retrying the
166+
// request once without keep-alive (Connection: close) reliably works around it.
167+
// See https://github.com/firebase/firebase-tools/issues/10692 and
168+
// https://github.com/node-fetch/node-fetch/issues/1767.
169+
const httpAgentNoKeepAlive = new http.Agent({ keepAlive: false });
170+
const httpsAgentNoKeepAlive = new https.Agent({ keepAlive: false });
171+
function noKeepAliveAgent(parsedURL: URL): http.Agent | https.Agent {
172+
return parsedURL.protocol === "https:" ? httpsAgentNoKeepAlive : httpAgentNoKeepAlive;
173+
}
174+
175+
function isPrematureCloseError(err: unknown): boolean {
176+
for (const candidate of [err, (err as { original?: unknown } | undefined)?.original]) {
177+
if (!candidate) {
178+
continue;
179+
}
180+
const e = candidate as { code?: string; errno?: string; message?: string };
181+
const code = e.code || e.errno;
182+
const message = typeof e.message === "string" ? e.message : "";
183+
if (
184+
code === "ERR_STREAM_PREMATURE_CLOSE" ||
185+
code === "ECONNRESET" ||
186+
/premature close/i.test(message) ||
187+
/socket hang up/i.test(message)
188+
) {
189+
return true;
190+
}
191+
}
192+
return false;
193+
}
194+
160195
export type ClientOptions = {
161196
urlPrefix: string;
162197
apiVersion?: string;
@@ -408,8 +443,18 @@ export class Client {
408443
fetchOptions.signal = signal;
409444
}
410445

411-
if (typeof options.body === "string" || isStream(options.body)) {
446+
// A request can only be safely retried if its body can be sent again.
447+
// FormData is a single-use stream, so serialize it to a Buffer up front (the
448+
// CLI only sends small string forms this way, e.g. the OAuth token request).
449+
// Raw streams cannot be replayed and therefore disable the keep-alive retry.
450+
let bodyReplayable = true;
451+
if (typeof options.body === "string" || Buffer.isBuffer(options.body)) {
452+
fetchOptions.body = options.body;
453+
} else if (options.body instanceof FormData) {
454+
fetchOptions.body = options.body.getBuffer();
455+
} else if (isStream(options.body)) {
412456
fetchOptions.body = options.body;
457+
bodyReplayable = false;
413458
} else if (options.body !== undefined) {
414459
fetchOptions.body = JSON.stringify(options.body);
415460
}
@@ -431,6 +476,10 @@ export class Client {
431476
}
432477
const operation = retry.operation(operationOptions);
433478

479+
// Tracks whether we've already downgraded this request to a non-keep-alive
480+
// connection in response to a "Premature close" error (retried at most once).
481+
let disabledKeepAlive = false;
482+
434483
return await new Promise<ClientResponse<ResT>>((resolve, reject) => {
435484
// eslint-disable-next-line @typescript-eslint/no-misused-promises
436485
operation.attempt(async (currentAttempt): Promise<void> => {
@@ -490,6 +539,27 @@ export class Client {
490539
});
491540
}
492541
} catch (err: unknown) {
542+
// Work around a node-fetch/Node.js keep-alive bug that surfaces as a
543+
// "Premature close" error: retry the request once without keep-alive.
544+
if (
545+
!disabledKeepAlive &&
546+
bodyReplayable &&
547+
!proxyURIFromEnv() &&
548+
isPrematureCloseError(err)
549+
) {
550+
disabledKeepAlive = true;
551+
fetchOptions.agent = noKeepAliveAgent;
552+
// Use a fresh Headers instance so we don't mutate the shared options.
553+
const closeHeaders = new Headers(fetchOptions.headers);
554+
closeHeaders.set("Connection", "close");
555+
fetchOptions.headers = closeHeaders;
556+
logger.debug(
557+
`*** [apiv2] retrying ${fetchURL} without keep-alive after a premature close error`,
558+
);
559+
if (operation.retry(err instanceof Error ? err : new Error(`${err}`))) {
560+
return;
561+
}
562+
}
493563
return err instanceof FirebaseError ? reject(err) : reject(new FirebaseError(`${err}`));
494564
}
495565

src/extensions/checkProjectBilling.spec.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ describe("checkProjectBilling", () => {
4747
await checkProjectBilling.enableBilling(projectId);
4848
}
4949

50-
expect(listBillingAccountsStub.notCalled);
51-
expect(setBillingAccountStub.notCalled);
52-
expect(confirmStub.notCalled);
53-
expect(selectStub.notCalled);
50+
expect(listBillingAccountsStub.notCalled).to.be.true;
51+
expect(setBillingAccountStub.notCalled).to.be.true;
52+
expect(confirmStub.notCalled).to.be.true;
53+
expect(selectStub.notCalled).to.be.true;
5454
});
5555

5656
it("should list accounts if no billing account set, but accounts available.", async () => {
@@ -73,9 +73,11 @@ describe("checkProjectBilling", () => {
7373
await checkProjectBilling.enableBilling(projectId);
7474
}
7575

76-
expect(listBillingAccountsStub.calledOnce);
77-
expect(setBillingAccountStub.calledOnce);
78-
expect(setBillingAccountStub.calledWith(projectId, "test-cloud-billing-account-name"));
76+
expect(listBillingAccountsStub.calledOnce).to.be.true;
77+
expect(listBillingAccountsStub.calledWith(projectId)).to.be.true;
78+
expect(setBillingAccountStub.calledOnce).to.be.true;
79+
expect(setBillingAccountStub.calledWith(projectId, "test-cloud-billing-account-name")).to.be
80+
.true;
7981
});
8082

8183
it("should not list accounts if no billing accounts set or available.", async () => {
@@ -90,8 +92,9 @@ describe("checkProjectBilling", () => {
9092
await checkProjectBilling.enableBilling(projectId);
9193
}
9294

93-
expect(listBillingAccountsStub.calledOnce);
94-
expect(setBillingAccountStub.notCalled);
95+
expect(listBillingAccountsStub.calledOnce).to.be.true;
96+
expect(listBillingAccountsStub.calledWith(projectId)).to.be.true;
97+
expect(setBillingAccountStub.notCalled).to.be.true;
9598
expect(checkBillingEnabledStub.callCount).to.equal(2);
9699
});
97100
});

src/extensions/checkProjectBilling.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async function openBillingAccount(projectId: string, url: string, open: boolean)
4040
"Press enter when finished upgrading your project to continue setting up your extension.",
4141
default: true,
4242
});
43-
return cloudbilling.checkBillingEnabled(projectId);
43+
return cloudbilling.checkBillingEnabled(projectId, true);
4444
}
4545

4646
/**
@@ -99,7 +99,7 @@ async function setUpBillingAccount(projectId: string) {
9999
* @param {string} projectId
100100
*/
101101
export async function enableBilling(projectId: string): Promise<void> {
102-
const billingAccounts = await cloudbilling.listBillingAccounts();
102+
const billingAccounts = await cloudbilling.listBillingAccounts(projectId);
103103
if (billingAccounts) {
104104
const accounts = billingAccounts.filter((account) => account.open);
105105
return accounts.length > 0

0 commit comments

Comments
 (0)