Skip to content

Commit 78b62e4

Browse files
authored
Merge branch 'main' into local_build_fix_abiu
2 parents 2767283 + 7781a66 commit 78b62e4

16 files changed

Lines changed: 418 additions & 59 deletions

CHANGELOG.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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()`.

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.3",
44
"description": "Command-Line Interface for Firebase",
55
"main": "./lib/index.js",
66
"mcpName": "io.github.firebase/firebase-mcp",

scripts/publish/cloudbuild.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,16 +134,17 @@ steps:
134134
sleep 60
135135
echo "Package firebase-tools@$(cat /workspace/version_number.txt) is now available on npm."
136136
137-
# Set up the hub credentials for firepit-builder.
137+
# Set up the hub and npm credentials for firepit-builder.
138138
- name: "gcr.io/$PROJECT_ID/firepit-builder"
139139
entrypoint: "bash"
140140
args:
141141
- "-c"
142142
- |
143143
if [ "${_VERSION}" != "preview" ]; then
144144
mkdir -vp ~/.config && cp -v hub ~/.config/hub
145+
cp -v npmrc ~/.npmrc
145146
else
146-
echo "Skipping hub credentials for firepit-builder for preview."
147+
echo "Skipping credentials for firepit-builder for preview."
147148
fi
148149
149150
# Publish the firepit builds.

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+
export 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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { expect } from "chai";
2+
import { Endpoint } from "../backend";
3+
import * as authEventarc from "./authEventarc";
4+
5+
const projectNumber = "123456789";
6+
7+
const endpoint: Endpoint = {
8+
id: "endpoint",
9+
region: "us-central1",
10+
project: projectNumber,
11+
eventTrigger: {
12+
retry: false,
13+
eventType: "google.firebase.auth.user.v2.created",
14+
eventFilters: {},
15+
},
16+
entryPoint: "endpoint",
17+
platform: "gcfv2",
18+
runtime: "nodejs16",
19+
};
20+
21+
describe("ensureAuthEventarcTriggerRegion", () => {
22+
it("should set the trigger location to global", async () => {
23+
const ep = { ...endpoint, eventTrigger: { ...endpoint.eventTrigger } };
24+
25+
await authEventarc.ensureAuthEventarcTriggerRegion(ep);
26+
27+
expect(ep.eventTrigger.region).to.eq("global");
28+
});
29+
30+
it("should not error if the trigger location is global", async () => {
31+
const ep = { ...endpoint, eventTrigger: { ...endpoint.eventTrigger } };
32+
ep.eventTrigger.region = "global";
33+
34+
await authEventarc.ensureAuthEventarcTriggerRegion(ep);
35+
36+
expect(ep.eventTrigger.region).to.eq("global");
37+
});
38+
39+
it("should error if the trigger location is not global", () => {
40+
const ep = { ...endpoint, eventTrigger: { ...endpoint.eventTrigger } };
41+
ep.eventTrigger.region = "us-west1";
42+
43+
expect(() => authEventarc.ensureAuthEventarcTriggerRegion(ep)).to.throw(
44+
"A Firebase Auth Eventarc trigger must specify 'global' trigger location",
45+
);
46+
});
47+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as backend from "../backend";
2+
import { FirebaseError } from "../../../error";
3+
4+
/**
5+
* Sets a Firebase Auth Eventarc event trigger's region to 'global' since the service is global.
6+
* @param endpoint the auth eventarc endpoint
7+
*/
8+
export function ensureAuthEventarcTriggerRegion(
9+
endpoint: backend.Endpoint & backend.EventTriggered,
10+
): Promise<void> {
11+
if (!endpoint.eventTrigger.region) {
12+
endpoint.eventTrigger.region = "global";
13+
}
14+
if (endpoint.eventTrigger.region !== "global") {
15+
throw new FirebaseError(
16+
"A Firebase Auth Eventarc trigger must specify 'global' trigger location",
17+
);
18+
}
19+
return Promise.resolve();
20+
}

src/deploy/functions/services/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
getDefaultRegion as ensureDataConnectDefaultRegion,
2525
} from "./dataconnect";
2626
import { AILogicService } from "./ailogic";
27+
import { ensureAuthEventarcTriggerRegion } from "./authEventarc";
2728

2829
/** A standard void No Op */
2930
export const noop = (): Promise<void> => Promise.resolve();
@@ -49,7 +50,8 @@ export type Name =
4950
| "testlab"
5051
| "firestore"
5152
| "dataconnect"
52-
| "ailogic";
53+
| "ailogic"
54+
| "autheventarc";
5355

5456
/** A service interface for the underlying GCP event services */
5557
export interface Service {
@@ -177,6 +179,18 @@ const dataconnectService: Service = {
177179
getDefaultRegion: ensureDataConnectDefaultRegion,
178180
};
179181

182+
/** A Firebase Auth Eventarc service object */
183+
const authEventarcService: Service = {
184+
name: "autheventarc",
185+
api: "eventarc.googleapis.com",
186+
requiredProjectBindings: noopProjectBindings,
187+
ensureTriggerRegion: ensureAuthEventarcTriggerRegion,
188+
validateTrigger: noop,
189+
registerTrigger: noop,
190+
unregisterTrigger: noop,
191+
getDefaultRegion: () => Promise.resolve(DEFAULT_GLOBAL_TRIGGER_REGION),
192+
};
193+
180194
/** Mapping from event type string to service object */
181195
// TODO: See if there's a way to deduplicate these consts while still ensuring type safety and exhaustion
182196
const EVENT_SERVICE_MAPPING: Record<events.Event, Service> = {
@@ -207,6 +221,8 @@ const EVENT_SERVICE_MAPPING: Record<events.Event, Service> = {
207221
"google.firebase.dataconnect.connector.v1.mutationExecuted": dataconnectService,
208222
"google.firebase.ailogic.v1.beforeGenerate": aiLogicService,
209223
"google.firebase.ailogic.v1.afterGenerate": aiLogicService,
224+
"google.firebase.auth.user.v2.created": authEventarcService,
225+
"google.firebase.auth.user.v2.deleted": authEventarcService,
210226
};
211227

212228
export function serviceForEndpoint(endpoint: backend.Endpoint | build.Endpoint): Service {

0 commit comments

Comments
 (0)