diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c870566b5..22c44646652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ - Configured OneMCP server tools to require a Firebase project by default, with options to opt-out specific tools (such as Developer Knowledge document search). +- Fixed a bug where deploying Dart functions with dependencies that use native build hooks (e.g. `sqlite3`) failed to compile. Note: Dart functions now require Dart SDK 3.13.0 or later. - Fixed a bug where deploying functions with the `dartfunctions` experiment enabled could incorrectly prompt to delete existing GCF v2 functions. - Added `outputSchema` support for local MCP tools. - Skip functions lifecycle hooks during partial (filtered) deployments, and print instructions for running them manually. diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 02dcd0916f2..3c6b4179d47 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -35,6 +35,55 @@ describe("partition env helper", () => { }); }); +describe("getExecutablePaths", () => { + it("returns the dart bundle executable path for a dart runtime", () => { + expect(prepare.getExecutablePaths(latest("dart"))).to.deep.equal([ + "build/cli/linux_x64/bundle/bin/server", + ]); + }); + + it("returns no executable paths for a non-dart runtime", () => { + expect(prepare.getExecutablePaths(latest("nodejs"))).to.deep.equal([]); + }); + + it("returns no executable paths when the runtime is undefined", () => { + expect(prepare.getExecutablePaths(undefined)).to.deep.equal([]); + }); +}); + +describe("stripStaleDartBuildIgnore", () => { + it("strips a stale 'build' entry from a dart codebase's ignore list", () => { + const localCfg = { source: "functions", ignore: [".dart_tool", "build"] }; + expect(prepare.stripStaleDartBuildIgnore(latest("dart"), localCfg)).to.deep.equal({ + source: "functions", + ignore: [".dart_tool"], + }); + }); + + it("strips a stale 'build/' entry from a dart codebase's ignore list", () => { + const localCfg = { source: "functions", ignore: [".dart_tool", "build/"] }; + expect(prepare.stripStaleDartBuildIgnore(latest("dart"), localCfg)).to.deep.equal({ + source: "functions", + ignore: [".dart_tool"], + }); + }); + + it("leaves a dart codebase's ignore list untouched when it has no stale 'build' entry", () => { + const localCfg = { source: "functions", ignore: [".dart_tool"] }; + expect(prepare.stripStaleDartBuildIgnore(latest("dart"), localCfg)).to.deep.equal(localCfg); + }); + + it("leaves a non-dart codebase's ignore list untouched", () => { + const localCfg = { source: "functions", ignore: ["node_modules", "build"] }; + expect(prepare.stripStaleDartBuildIgnore(latest("nodejs"), localCfg)).to.deep.equal(localCfg); + }); + + it("leaves a codebase with no ignore list untouched", () => { + const localCfg: { source: string; ignore?: string[] } = { source: "functions" }; + expect(prepare.stripStaleDartBuildIgnore(latest("dart"), localCfg)).to.deep.equal(localCfg); + }); +}); + describe("prepare", () => { const ENDPOINT_BASE: Omit = { platform: "gcfv2", diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 7e13b1094e2..316696991e7 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -33,6 +33,7 @@ import { } from "./functionsDeployHelper"; import { logLabeledBullet, logLabeledWarning } from "../../utils"; import { isDartEndpoint, classifyNonProductionEndpoints } from "./runtimes/dart/triggerSupport"; +import { DART_BUNDLE_EXECUTABLE_PATH } from "./runtimes/dart"; import { getFunctionsConfig, prepareFunctionsUpload } from "./prepareFunctionsUpload"; import { promptForFailurePolicies, promptForMinInstances } from "./prompts"; import { needProjectId, needProjectNumber } from "../../projectUtils"; @@ -430,13 +431,13 @@ export async function prepare( ? "tar.gz" : "zip"; - const isDart = supported.runtimeIsLanguage(wantBuilds[codebase].runtime, "dart"); - const executablePaths = isDart ? ["bin/server"] : []; + const executablePaths = getExecutablePaths(wantBuilds[codebase].runtime); + const uploadCfg = stripStaleDartBuildIgnore(wantBuilds[codebase].runtime, localCfg); const packagedSource = await prepareFunctionsUpload( options.config.projectDir, sourceDir, - localCfg, + uploadCfg, [...schPathSet], undefined, { exportType, executablePaths }, @@ -842,6 +843,39 @@ function warnIfDartBackendHasUnsupportedTriggers(want: backend.Backend): void { } } +/** + * Returns the executable paths to mark as executable when packaging a codebase's source, + * relative to the runtime in use. + */ +export function getExecutablePaths(runtime: supported.Runtime | undefined): string[] { + return supported.runtimeIsLanguage(runtime, "dart") ? [DART_BUNDLE_EXECUTABLE_PATH] : []; +} + +/** + * Strips a stale "build" ignore entry from a Dart codebase's local config. + * + * Before the switch to `dart build cli`, `firebase init` seeded Dart codebases with + * `functions.ignore` including "build", which was harmless since the compiled executable + * lived at `bin/server`. The bundle now lives under `build/` (see + * DART_BUNDLE_EXECUTABLE_PATH), so honoring that stale entry for codebases configured + * before this fix would silently strip the executable from the deploy archive. + */ +export function stripStaleDartBuildIgnore( + runtime: supported.Runtime | undefined, + localCfg: T, +): T { + if ( + !supported.runtimeIsLanguage(runtime, "dart") || + !localCfg.ignore?.some((i) => i === "build" || i === "build/") + ) { + return localCfg; + } + return { + ...localCfg, + ignore: localCfg.ignore.filter((i) => i !== "build" && i !== "build/"), + }; +} + /** * Genkit almost always requires an API key, so warn if the customer is about to deploy * a function and doesn't have one. To avoid repetitive nagging, only warn on the first diff --git a/src/deploy/functions/runtimes/dart/index.spec.ts b/src/deploy/functions/runtimes/dart/index.spec.ts index 2259d4de4a4..d90b30d2d43 100644 --- a/src/deploy/functions/runtimes/dart/index.spec.ts +++ b/src/deploy/functions/runtimes/dart/index.spec.ts @@ -1,11 +1,105 @@ import { expect } from "chai"; import * as sinon from "sinon"; -import { Delegate } from "./index"; +import * as childProcess from "child_process"; +import { EventEmitter } from "events"; +import { ChildProcess } from "child_process"; +import { Delegate, DART_ENTRY_POINT } from "./index"; import * as discovery from "../discovery"; import * as build from "../../build"; import * as supported from "../supported"; +import { FirebaseError } from "../../../../error"; +import { EmulatorRegistry } from "../../../../emulator/registry"; +import { Emulators } from "../../../../emulator/types"; + +function createFakeChildProcess(exitCode: number | null): ChildProcess { + const proc = new EventEmitter() as ChildProcess; + (proc as unknown as { stdout: EventEmitter }).stdout = new EventEmitter(); + (proc as unknown as { stderr: EventEmitter }).stderr = new EventEmitter(); + process.nextTick(() => proc.emit("exit", exitCode)); + return proc; +} describe("Dart Runtime Delegate", () => { + describe("validate", () => { + afterEach(() => { + sinon.restore(); + }); + + it("should reject a Dart SDK older than the minimum required version", async () => { + sinon.stub(childProcess, "spawnSync").returns({ + stdout: 'Dart SDK version: 3.9.0 (stable) (Thu Jan 1 2026) on "linux_x64"', + stderr: "", + status: 0, + signal: null, + pid: 1, + output: [], + } as unknown as ReturnType); + + const delegate = new Delegate("project", "sourceDir", supported.latest("dart")); + + await expect(delegate.validate()).to.be.rejectedWith( + FirebaseError, + /Dart SDK version 3\.9\.0 is not supported.*requires Dart 3\.13\.0 or later/, + ); + }); + }); + + describe("build", () => { + let spawnStub: sinon.SinonStub; + + beforeEach(() => { + spawnStub = sinon.stub(childProcess, "spawn"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should invoke `dart build cli` with the target/os/arch flags", async () => { + spawnStub.callsFake(() => createFakeChildProcess(0)); + + const delegate = new Delegate("project", "sourceDir", supported.latest("dart")); + await delegate.build(); + + expect(spawnStub.callCount).to.equal(2); + const [command, args] = spawnStub.secondCall.args as [string, string[]]; + expect(command).to.equal("dart"); + expect(args).to.deep.equal([ + "build", + "cli", + "--target", + DART_ENTRY_POINT, + "--target-os", + "linux", + "--target-arch", + "x64", + ]); + }); + + it("should throw a FirebaseError with the dart build cli remediation command on failure", async () => { + spawnStub.onFirstCall().callsFake(() => createFakeChildProcess(0)); + spawnStub.onSecondCall().callsFake(() => createFakeChildProcess(1)); + + const delegate = new Delegate("project", "sourceDir", supported.latest("dart")); + + await expect(delegate.build()).to.be.rejectedWith( + FirebaseError, + /dart build cli --target bin\/server\.dart --target-os linux --target-arch x64/, + ); + }); + + it("should skip `dart build cli` when the functions emulator is running", async () => { + spawnStub.callsFake(() => createFakeChildProcess(0)); + sinon.stub(EmulatorRegistry, "isRunning").withArgs(Emulators.FUNCTIONS).returns(true); + + const delegate = new Delegate("project", "sourceDir", supported.latest("dart")); + await delegate.build(); + + // Only build_runner should have been spawned; `dart build cli` is skipped. + expect(spawnStub.callCount).to.equal(1); + }); + }); + describe("discoverBuild", () => { let detectFromYamlStub: sinon.SinonStub; diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 13d3bb2a8d4..326c1bc8a59 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -47,13 +47,17 @@ export async function tryCreateDelegate( /** * Minimum Dart SDK version required. - * Dart 3.8+ is needed for cross-compilation flags (--target-os, --target-arch). + * Dart 3.13+ is needed for `dart build cli` to support cross-compilation flags + * (--target-os, --target-arch). */ -const MIN_DART_SDK_VERSION = "3.9.0"; +const MIN_DART_SDK_VERSION = "3.13.0"; /** Default entry point for Dart functions projects. */ export const DART_ENTRY_POINT = "bin/server.dart"; +/** Path to the executable produced by `dart build cli` for a linux-x64 target. */ +export const DART_BUNDLE_EXECUTABLE_PATH = "build/cli/linux_x64/bundle/bin/server"; + export class Delegate implements runtimes.RuntimeDelegate { public readonly language = "dart"; public readonly bin = "dart"; @@ -200,53 +204,42 @@ export class Delegate implements runtimes.RuntimeDelegate { return; } - const binDir = path.join(this.sourceDir, "bin"); - await fs.promises.mkdir(binDir, { recursive: true }); - - logLabeledBullet("functions", "compiling Dart to linux-x64 executable..."); + logLabeledBullet("functions", "building Dart linux-x64 bundle..."); - const compileProcess = spawn( + const buildProcess = spawn( this.bin, - [ - "compile", - "exe", - this.entryPoint, - "-o", - "bin/server", - "--target-os=linux", - "--target-arch=x64", - ], + ["build", "cli", "--target", this.entryPoint, "--target-os", "linux", "--target-arch", "x64"], { cwd: this.sourceDir, stdio: ["ignore", "pipe", "pipe"], }, ); - compileProcess.stdout?.on("data", (chunk: Buffer) => { - logger.debug(`[dart compile] ${chunk.toString("utf8").trim()}`); + buildProcess.stdout?.on("data", (chunk: Buffer) => { + logger.debug(`[dart build cli] ${chunk.toString("utf8").trim()}`); }); - compileProcess.stderr?.on("data", (chunk: Buffer) => { - logger.debug(`[dart compile] ${chunk.toString("utf8").trim()}`); + buildProcess.stderr?.on("data", (chunk: Buffer) => { + logger.debug(`[dart build cli] ${chunk.toString("utf8").trim()}`); }); await new Promise((resolve, reject) => { - compileProcess.on("exit", (code) => { + buildProcess.on("exit", (code) => { if (code === 0 || code === null) { resolve(); } else { reject( new FirebaseError( - `Dart compilation failed with exit code ${code}. ` + - `Make sure your Dart project compiles successfully with: ` + - `dart compile exe ${this.entryPoint} --target-os=linux --target-arch=x64`, + `Dart build failed with exit code ${code}. ` + + `Make sure your Dart project builds successfully with: ` + + `dart build cli --target ${this.entryPoint} --target-os linux --target-arch x64`, ), ); } }); - compileProcess.on("error", reject); + buildProcess.on("error", reject); }); - logLabeledBullet("functions", "Dart compilation complete."); + logLabeledBullet("functions", "Dart build complete."); } /** diff --git a/src/init/features/functions/dart.ts b/src/init/features/functions/dart.ts index e03c90b6696..846c8de45da 100644 --- a/src/init/features/functions/dart.ts +++ b/src/init/features/functions/dart.ts @@ -23,8 +23,9 @@ export async function setup(setup: any, config: Config): Promise { // Write the latest supported runtime version to the config. config.set("functions.runtime", latest("dart")); - // Add dart specific ignores to config. - config.set("functions.ignore", [".dart_tool", "build"]); + // Add dart specific ignores to config. `build/` is intentionally not ignored: + // `dart build cli` writes the deployable bundle there (see DART_BUNDLE_EXECUTABLE_PATH). + config.set("functions.ignore", [".dart_tool"]); const install = await confirm({ message: "Do you want to install dependencies now?", diff --git a/templates/init/functions/dart/_gitignore b/templates/init/functions/dart/_gitignore index 2b74f7c8a8c..5e4070e519b 100644 --- a/templates/init/functions/dart/_gitignore +++ b/templates/init/functions/dart/_gitignore @@ -1,3 +1,3 @@ .dart_tool/ -bin/server +build/ *.local