Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
49 changes: 49 additions & 0 deletions src/deploy/functions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<backend.Endpoint, "httpsTrigger"> = {
platform: "gcfv2",
Expand Down
40 changes: 37 additions & 3 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Comment thread
demolaf marked this conversation as resolved.
const packagedSource = await prepareFunctionsUpload(
options.config.projectDir,
sourceDir,
localCfg,
uploadCfg,
[...schPathSet],
undefined,
{ exportType, executablePaths },
Expand Down Expand Up @@ -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<T extends { ignore?: string[] }>(
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
Expand Down
96 changes: 95 additions & 1 deletion src/deploy/functions/runtimes/dart/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof childProcess.spawnSync>);

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;

Expand Down
45 changes: 19 additions & 26 deletions src/deploy/functions/runtimes/dart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void>((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.");
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/init/features/functions/dart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ export async function setup(setup: any, config: Config): Promise<void> {

// 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?",
Expand Down
2 changes: 1 addition & 1 deletion templates/init/functions/dart/_gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
.dart_tool/
bin/server
build/
*.local