From f107067d650f13c85971b9fde5f07d81a1f47c39 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 11 Nov 2025 14:57:18 +0000 Subject: [PATCH 01/21] feat: Add dart delegate --- src/deploy/functions/runtimes/dart/index.ts | 198 ++++++++++++++++++ src/deploy/functions/runtimes/index.ts | 3 +- .../functions/runtimes/supported/types.ts | 9 +- src/emulator/functionsEmulator.ts | 109 ++++++++-- src/emulator/functionsRuntimeWorker.ts | 39 ++-- src/init/features/functions/dart.ts | 87 ++++++++ src/init/features/functions/index.ts | 16 ++ 7 files changed, 429 insertions(+), 32 deletions(-) create mode 100644 src/deploy/functions/runtimes/dart/index.ts create mode 100644 src/init/features/functions/dart.ts diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts new file mode 100644 index 00000000000..7b6937f4b00 --- /dev/null +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -0,0 +1,198 @@ +import * as fs from "fs"; +import * as path from "path"; +import { promisify } from "util"; +import { ChildProcess } from "child_process"; +import * as spawn from "cross-spawn"; + +import * as runtimes from ".."; +import * as backend from "../../backend"; +import * as discovery from "../discovery"; +import * as supported from "../supported"; +import { logger } from "../../../../logger"; +import { FirebaseError } from "../../../../error"; +import { Build } from "../../build"; + +/** + * Create a runtime delegate for the Dart runtime, if applicable. + * @param context runtimes.DelegateContext + * @return Delegate Dart runtime delegate + */ +export async function tryCreateDelegate( + context: runtimes.DelegateContext, +): Promise { + const pubspecYamlPath = path.join(context.sourceDir, "pubspec.yaml"); + + if (!(await promisify(fs.exists)(pubspecYamlPath))) { + logger.debug("Customer code is not Dart code."); + return; + } + const runtime = context.runtime ?? supported.latest("dart"); + if (!supported.isRuntime(runtime)) { + throw new FirebaseError(`Runtime ${runtime as string} is not a valid Dart runtime`); + } + if (!supported.runtimeIsLanguage(runtime, "dart")) { + throw new FirebaseError( + `Internal error. Trying to construct a dart runtime delegate for runtime ${runtime}`, + { exit: 1 }, + ); + } + return Promise.resolve(new Delegate(context.projectId, context.sourceDir, runtime)); +} + +export class Delegate implements runtimes.RuntimeDelegate { + public readonly language = "dart"; + constructor( + private readonly projectId: string, + private readonly sourceDir: string, + public readonly runtime: supported.Runtime & supported.RuntimeOf<"dart">, + ) {} + + private _bin = ""; + + get bin(): string { + if (this._bin === "") { + this._bin = "dart"; + } + return this._bin; + } + + async validate(): Promise { + // Basic validation: check that pubspec.yaml exists and is readable + const pubspecYamlPath = path.join(this.sourceDir, "pubspec.yaml"); + try { + await fs.promises.access(pubspecYamlPath, fs.constants.R_OK); + // TODO: could add more validation like checking for firebase_functions dependency + } catch (err: any) { + throw new FirebaseError( + `Failed to read pubspec.yaml at ${pubspecYamlPath}: ${err.message}`, + ); + } + } + + async build(): Promise { + // No-op: build_runner handles building + return Promise.resolve(); + } + + watch(): Promise<() => Promise> { + const dartRunProcess = spawn(this.bin, ["run", this.sourceDir], { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }); + + const buildRunnerProcess = spawn(this.bin, ["run", "build_runner", "watch", "-d"], { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }); + + // Log output from both processes + dartRunProcess.stdout?.on("data", (chunk: Buffer) => { + logger.info(`[dart run] ${chunk.toString("utf8")}`); + }); + dartRunProcess.stderr?.on("data", (chunk: Buffer) => { + logger.error(`[dart run] ${chunk.toString("utf8")}`); + }); + + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { + logger.info(`[build_runner] ${chunk.toString("utf8")}`); + }); + buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { + logger.error(`[build_runner] ${chunk.toString("utf8")}`); + }); + + // Return cleanup function + return Promise.resolve(async () => { + const killProcess = (proc: ChildProcess) => { + if (!proc.killed && proc.exitCode === null) { + proc.kill("SIGTERM"); + } + }; + + // Try graceful shutdown first + killProcess(dartRunProcess); + killProcess(buildRunnerProcess); + + // Wait a bit for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Force kill if still running + if (!dartRunProcess.killed && dartRunProcess.exitCode === null) { + dartRunProcess.kill("SIGKILL"); + } + if (!buildRunnerProcess.killed && buildRunnerProcess.exitCode === null) { + buildRunnerProcess.kill("SIGKILL"); + } + + // Wait for both processes to exit + await Promise.all([ + new Promise((resolve) => { + if (dartRunProcess.killed || dartRunProcess.exitCode !== null) { + resolve(); + } else { + dartRunProcess.once("exit", () => resolve()); + } + }), + new Promise((resolve) => { + if (buildRunnerProcess.killed || buildRunnerProcess.exitCode !== null) { + resolve(); + } else { + buildRunnerProcess.once("exit", () => resolve()); + } + }), + ]); + }); + } + + async discoverBuild( + _configValues: backend.RuntimeConfigValues, + _envs: backend.EnvironmentVariables, + ): Promise { + // Use file-based discovery from .dart_tool/firebase/functions.yaml + const yamlDir = path.join(this.sourceDir, ".dart_tool", "firebase"); + const yamlPath = path.join(yamlDir, "functions.yaml"); + let discovered = await discovery.detectFromYaml(yamlDir, this.projectId, this.runtime); + + if (!discovered) { + // If the file doesn't exist yet, run build_runner to generate it + logger.debug("functions.yaml not found, running build_runner to generate it..."); + const buildRunnerProcess = spawn(this.bin, ["run", "build_runner", "build"], { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }); + + // Log build_runner output + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { + logger.debug(`[build_runner] ${chunk.toString("utf8")}`); + }); + buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { + logger.debug(`[build_runner] ${chunk.toString("utf8")}`); + }); + + await new Promise((resolve, reject) => { + buildRunnerProcess.on("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + } else { + reject( + new FirebaseError( + `build_runner failed with exit code ${code}. Make sure your Dart project is properly configured.`, + ), + ); + } + }); + buildRunnerProcess.on("error", reject); + }); + + // Try to discover again after build_runner completes + discovered = await discovery.detectFromYaml(yamlDir, this.projectId, this.runtime); + if (!discovered) { + throw new FirebaseError( + `Could not find functions.yaml at ${yamlPath} after running build_runner. ` + + `Make sure your Dart project is properly configured with firebase_functions.`, + ); + } + } + + return discovered; + } +} diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index cb3aa66d1ab..d8af6cdb89d 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -1,5 +1,6 @@ import * as backend from "../backend"; import * as build from "../build"; +import * as dart from "./dart"; import * as node from "./node"; import * as python from "./python"; import * as validate from "../validate"; @@ -70,7 +71,7 @@ export interface DelegateContext { } type Factory = (context: DelegateContext) => Promise; -const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate]; +const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate, dart.tryCreateDelegate]; /** * Gets the delegate object responsible for discovering, building, and hosting diff --git a/src/deploy/functions/runtimes/supported/types.ts b/src/deploy/functions/runtimes/supported/types.ts index 82f46998626..515a6232762 100644 --- a/src/deploy/functions/runtimes/supported/types.ts +++ b/src/deploy/functions/runtimes/supported/types.ts @@ -9,7 +9,7 @@ export type RuntimeStatus = "experimental" | "beta" | "GA" | "deprecated" | "dec type Day = `${number}-${number}-${number}`; /** Supported languages. All Runtime are a language + version. */ -export type Language = "nodejs" | "python"; +export type Language = "nodejs" | "python" | "dart"; /** * Helper type that is more friendlier than string interpolation everywhere. @@ -113,6 +113,13 @@ export const RUNTIMES = runtimes({ deprecationDate: "2029-10-10", decommissionDate: "2030-04-10", }, + dart3: { + friendly: "Dart 3", + status: "GA", + // TODO: Check these + deprecationDate: "2027-10-01", + decommissionDate: "2028-04-01", + }, }); export type Runtime = keyof typeof RUNTIMES & RuntimeOf; diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 88055cb30f3..32c6843434e 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -401,7 +401,8 @@ export class FunctionsEmulator implements EmulatorInstance { async sendRequest(trigger: EmulatedTriggerDefinition, body?: any) { const record = this.getTriggerRecordByKey(this.getTriggerKey(trigger)); const pool = this.workerPools[record.backend.codebase]; - if (!pool.readyForWork(trigger.id)) { + const runtime = record.backend.runtime; + if (!pool.readyForWork(trigger.id, runtime)) { try { await this.startRuntime(record.backend, trigger); } catch (e: any) { @@ -409,7 +410,7 @@ export class FunctionsEmulator implements EmulatorInstance { return; } } - const worker = pool.getIdleWorker(trigger.id)!; + const worker = pool.getIdleWorker(trigger.id, runtime)!; if (this.debugMode) { await worker.sendDebugMsg({ functionTarget: trigger.entryPoint, @@ -421,11 +422,18 @@ export class FunctionsEmulator implements EmulatorInstance { "Content-Type": "application/json", "Content-Length": `${reqBody.length}`, }; + + // For Dart, include the function name in the path so the server can route + // For other runtimes, use / as they use FUNCTION_TARGET env var + const isDart = runtime?.startsWith("dart"); + const path = isDart ? `/${trigger.entryPoint}` : `/`; + return new Promise((resolve, reject) => { const req = http.request( { ...worker.runtime.conn.httpReqOpts(), - path: `/`, + method: "POST", + path: path, headers: headers, }, resolve, @@ -475,14 +483,22 @@ export class FunctionsEmulator implements EmulatorInstance { `Watching "${backend.functionsDir}" for Cloud Functions...`, ); - const watcher = chokidar.watch(backend.functionsDir, { - ignored: [ - /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules - /(^|[\/\\])\../, // Ignore files which begin the a period - /.+\.log/, // Ignore files which have a .log extension - /.+?[\\\/]venv[\\\/].+?/, // Ignore site-packages in venv - ...(backend.ignore?.map((i) => `**/${i}`) ?? []), - ], + // For Dart runtimes, watch only the YAML spec file since Dart handles its own hot reload + const isDart = backend.runtime?.startsWith("dart"); + const watchPath = isDart + ? path.join(backend.functionsDir, ".dart_tool", "firebase", "functions.yaml") + : backend.functionsDir; + + const watcher = chokidar.watch(watchPath, { + ignored: isDart + ? [] // For Dart, we're watching a specific file, so no ignore patterns needed + : [ + /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules + /(^|[\/\\])\../, // Ignore files which begin the a period + /.+\.log/, // Ignore files which have a .log extension + /.+?[\\\/]venv[\\\/].+?/, // Ignore site-packages in venv + ...(backend.ignore?.map((i) => `**/${i}`) ?? []), + ], persistent: true, }); @@ -1670,6 +1686,59 @@ export class FunctionsEmulator implements EmulatorInstance { }; } + async startDart( + backend: EmulatableBackend, + envs: Record, + ): Promise { + if (this.debugMode) { + this.logger.log("WARN", "--inspect-functions not supported for Dart functions. Ignored."); + } + + // Use TCP/IP stack for Dart, similar to Python + const port = await portfinder.getPortPromise({ + port: 8081 + randomInt(0, 1000), // Add a small jitter to avoid race condition. + }); + + const args = ["--enable-vm-service", "lib/main.dart"]; + + // For Dart, don't set FUNCTION_TARGET in environment - the server loads all functions + // and routes based on the request path (similar to Python's functions-framework) + const dartEnvs = { ...envs }; + delete dartEnvs.FUNCTION_TARGET; + delete dartEnvs.FUNCTION_SIGNATURE_TYPE; + + const bin = backend.bin || "dart"; + logger.debug( + `Starting Dart runtime with args: ${args.join(" ")} on port ${port}`, + ); + const childProcess = spawn(bin, args, { + cwd: backend.functionsDir, + env: { + ...process.env, + ...dartEnvs, + HOST: "127.0.0.1", + PORT: port.toString(), + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Log stdout and stderr for debugging + childProcess.stdout?.on("data", (chunk: Buffer) => { + this.logger.log("DEBUG", `[dart] ${chunk.toString("utf8")}`); + }); + + childProcess.stderr?.on("data", (chunk: Buffer) => { + this.logger.log("DEBUG", `[dart] ${chunk.toString("utf8")}`); + }); + + return { + process: childProcess, + events: new EventEmitter(), + cwd: backend.functionsDir, + conn: new TCPConn("127.0.0.1", port), + }; + } + async startRuntime( backend: EmulatableBackend, trigger?: EmulatedTriggerDefinition, @@ -1680,6 +1749,8 @@ export class FunctionsEmulator implements EmulatorInstance { let runtime; if (backend.runtime!.startsWith("python")) { runtime = await this.startPython(backend, { ...runtimeEnv, ...secretEnvs }); + } else if (backend.runtime!.startsWith("dart")) { + runtime = await this.startDart(backend, { ...runtimeEnv, ...secretEnvs }); } else { runtime = await this.startNode(backend, { ...runtimeEnv, ...secretEnvs }); } @@ -1689,7 +1760,7 @@ export class FunctionsEmulator implements EmulatorInstance { }; const pool = this.workerPools[backend.codebase]; - const worker = pool.addWorker(trigger, runtime, extensionLogInfo); + const worker = pool.addWorker(trigger, runtime, extensionLogInfo, backend.runtime); await worker.waitForSocketReady(); return worker; } @@ -1835,18 +1906,27 @@ export class FunctionsEmulator implements EmulatorInstance { // To match production behavior we need to drop the path prefix // req.url = /:projectId/:region/:trigger_name/* const url = new URL(`${req.protocol}://${req.hostname}${req.url}`); - const path = `${url.pathname}${url.search}`.replace( + let path = `${url.pathname}${url.search}`.replace( new RegExp(`\/${this.args.projectId}\/[^\/]*\/${req.params.trigger_name}\/?`), "/", ); + // For Dart, include the function name in the path so the server can route + // Python's functions-framework uses FUNCTION_TARGET env var, but Dart loads all functions + const isDart = record.backend.runtime?.startsWith("dart"); + if (isDart && path === "/") { + // Include function name in path for Dart routing + path = `/${trigger.entryPoint}`; + } + // We do this instead of just 302'ing because many HTTP clients don't respect 302s so it may // cause unexpected situations - not to mention CORS troubles and this enables us to use // a socketPath (IPC socket) instead of consuming yet another port which is probably faster as well. this.logger.log("DEBUG", `[functions] Got req.url=${req.url}, mapping to path=${path}`); const pool = this.workerPools[record.backend.codebase]; - if (!pool.readyForWork(trigger.id)) { + const runtime = record.backend.runtime; + if (!pool.readyForWork(trigger.id, runtime)) { try { await this.startRuntime(record.backend, trigger); } catch (e: any) { @@ -1875,6 +1955,7 @@ export class FunctionsEmulator implements EmulatorInstance { res as http.ServerResponse, reqBody, debugBundle, + runtime, ); } } diff --git a/src/emulator/functionsRuntimeWorker.ts b/src/emulator/functionsRuntimeWorker.ts index 5c32326f296..9242da4b23a 100644 --- a/src/emulator/functionsRuntimeWorker.ts +++ b/src/emulator/functionsRuntimeWorker.ts @@ -296,12 +296,16 @@ export class RuntimeWorkerPool { constructor(private mode: FunctionsExecutionMode = FunctionsExecutionMode.AUTO) {} - getKey(triggerId: string | undefined): string { + getKey(triggerId: string | undefined, runtime?: string): string { if (this.mode === FunctionsExecutionMode.SEQUENTIAL) { return "~shared~"; - } else { - return triggerId || "~diagnostic~"; } + // For Dart, use a shared key so all functions in a codebase share the same process + // Dart loads all functions and routes based on request path + if (runtime?.startsWith("dart")) { + return "~dart-shared~"; + } + return triggerId || "~diagnostic~"; } /** @@ -345,8 +349,8 @@ export class RuntimeWorkerPool { * * @param triggerId */ - readyForWork(triggerId: string | undefined): boolean { - const idleWorker = this.getIdleWorker(triggerId); + readyForWork(triggerId: string | undefined, runtime?: string): boolean { + const idleWorker = this.getIdleWorker(triggerId, runtime); return !!idleWorker; } @@ -366,9 +370,10 @@ export class RuntimeWorkerPool { resp: http.ServerResponse, body: unknown, debug?: FunctionsRuntimeBundle["debug"], + runtime?: string, ): Promise { this.log(`submitRequest(triggerId=${triggerId})`); - const worker = this.getIdleWorker(triggerId); + const worker = this.getIdleWorker(triggerId, runtime); if (!worker) { throw new FirebaseError( "Internal Error: can't call submitRequest without checking for idle workers", @@ -380,11 +385,11 @@ export class RuntimeWorkerPool { return worker.request(req, resp, body, !!debug); } - getIdleWorker(triggerId: string | undefined): RuntimeWorker | undefined { + getIdleWorker(triggerId: string | undefined, runtime?: string): RuntimeWorker | undefined { this.cleanUpWorkers(); - const triggerWorkers = this.getTriggerWorkers(triggerId); + const triggerWorkers = this.getTriggerWorkers(triggerId, runtime); if (!triggerWorkers.length) { - this.setTriggerWorkers(triggerId, []); + this.setTriggerWorkers(triggerId, [], runtime); return; } @@ -406,8 +411,10 @@ export class RuntimeWorkerPool { trigger: EmulatedTriggerDefinition | undefined, runtime: FunctionsRuntimeInstance, extensionLogInfo: ExtensionLogInfo, + runtimeType?: string, ): RuntimeWorker { - this.log(`addWorker(${this.getKey(trigger?.id)})`); + const key = this.getKey(trigger?.id, runtimeType); + this.log(`addWorker(${key})`); // Disable worker timeout if: // (1) This is a diagnostic call without trigger id OR // (2) If in SEQUENTIAL execution mode @@ -419,20 +426,20 @@ export class RuntimeWorkerPool { disableTimeout ? undefined : trigger?.timeoutSeconds, ); - const keyWorkers = this.getTriggerWorkers(trigger?.id); + const keyWorkers = this.getTriggerWorkers(trigger?.id, runtimeType); keyWorkers.push(worker); - this.setTriggerWorkers(trigger?.id, keyWorkers); + this.setTriggerWorkers(trigger?.id, keyWorkers, runtimeType); this.log(`Adding worker with key ${worker.triggerKey}, total=${keyWorkers.length}`); return worker; } - getTriggerWorkers(triggerId: string | undefined): Array { - return this.workers.get(this.getKey(triggerId)) || []; + getTriggerWorkers(triggerId: string | undefined, runtime?: string): Array { + return this.workers.get(this.getKey(triggerId, runtime)) || []; } - private setTriggerWorkers(triggerId: string | undefined, workers: Array) { - this.workers.set(this.getKey(triggerId), workers); + private setTriggerWorkers(triggerId: string | undefined, workers: Array, runtime?: string) { + this.workers.set(this.getKey(triggerId, runtime), workers); } private cleanUpWorkers() { diff --git a/src/init/features/functions/dart.ts b/src/init/features/functions/dart.ts new file mode 100644 index 00000000000..ce084a99da6 --- /dev/null +++ b/src/init/features/functions/dart.ts @@ -0,0 +1,87 @@ +import * as spawn from "cross-spawn"; +import { Config } from "../../../config"; +import { confirm } from "../../../prompt"; +import { latest } from "../../../deploy/functions/runtimes/supported"; + +// TODO(ehesp): Create these template files in templates/init/functions/dart/ +// For now, we'll use basic templates +// TODO(ehesp): Dont use relative path +// TODO(ehesp): Should +const PUBSPEC_TEMPLATE = `name: functions +description: Firebase Functions for Dart +version: 1.0.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + firebase_functions: + path: ../ + shelf: + +dev_dependencies: + build_runner: ^2.4.0 +`; + +const MAIN_TEMPLATE = `import 'package:firebase_functions/firebase_functions.dart'; +import 'package:shelf/shelf.dart'; + +void main(List args) { + fireUp(args, (firebase) { + + firebase.https.onRequest( + name: 'helloWorld', + options: const HttpsOptions(cors: Cors(['*'])), + (request) async { + return Response(200, body: 'Hello from Dart Functions!'); + }); + }); +} +`; + +const GITIGNORE_TEMPLATE = `.dart_tool/ +build/ +.dart_tool/ +*.dart.js +*.info.json +*.js +*.js.map +*.js.deps +*.js.symbols +firebase-debug.log +firebase-debug.*.log +*.local +`; + +/** + * Create a Dart Firebase Functions project. + */ +export async function setup(setup: any, config: Config): Promise { + await config.askWriteProjectFile( + `${setup.functions.source}/pubspec.yaml`, + PUBSPEC_TEMPLATE, + ); + await config.askWriteProjectFile(`${setup.functions.source}/.gitignore`, GITIGNORE_TEMPLATE); + await config.askWriteProjectFile(`${setup.functions.source}/lib/main.dart`, MAIN_TEMPLATE); + + // 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"]); + + const install = await confirm({ + message: "Do you want to install dependencies now?", + default: true, + }); + if (install) { + const installProcess = spawn("dart", ["pub", "get"], { + cwd: config.path(setup.functions.source), + stdio: ["inherit", "inherit", "inherit"], + }); + await new Promise((resolve, reject) => { + installProcess.on("exit", resolve); + installProcess.on("error", reject); + }); + } +} + diff --git a/src/init/features/functions/index.ts b/src/init/features/functions/index.ts index 1bf13d2d036..fd608dfc412 100644 --- a/src/init/features/functions/index.ts +++ b/src/init/features/functions/index.ts @@ -173,6 +173,10 @@ async function languageSetup(setup: any, config: Config): Promise { name: "Python", value: "python", }, + { + name: "Dart", + value: "dart", + }, ]; const language = await select({ message: "What language would you like to use to write Cloud Functions?", @@ -205,6 +209,18 @@ async function languageSetup(setup: any, config: Config): Promise { // but in theory this doesn't have to be the case. cbconfig.runtime = supported.latest("python") as supported.ActiveRuntime; break; + case "dart": + cbconfig.ignore = [ + ".dart_tool", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local", + ]; + // In practical sense, latest supported runtime will not be a decomissioned runtime, + // but in theory this doesn't have to be the case. + cbconfig.runtime = supported.latest("dart") as supported.ActiveRuntime; + break; } setup.functions.languageChoice = language; return require("./" + language).setup(setup, config); From 8f90eef2f043aa17dedf60c2f1e937cc6f01c861 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 6 Jan 2026 10:18:08 +0100 Subject: [PATCH 02/21] feat: add support for hot reloading --- src/deploy/functions/runtimes/dart/index.ts | 69 +---------- src/emulator/functionsEmulator.ts | 130 ++++++++++++++++++-- 2 files changed, 121 insertions(+), 78 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 7b6937f4b00..ef54d628dc3 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -1,7 +1,6 @@ import * as fs from "fs"; import * as path from "path"; import { promisify } from "util"; -import { ChildProcess } from "child_process"; import * as spawn from "cross-spawn"; import * as runtimes from ".."; @@ -75,72 +74,8 @@ export class Delegate implements runtimes.RuntimeDelegate { } watch(): Promise<() => Promise> { - const dartRunProcess = spawn(this.bin, ["run", this.sourceDir], { - cwd: this.sourceDir, - stdio: ["ignore", "pipe", "pipe"], - }); - - const buildRunnerProcess = spawn(this.bin, ["run", "build_runner", "watch", "-d"], { - cwd: this.sourceDir, - stdio: ["ignore", "pipe", "pipe"], - }); - - // Log output from both processes - dartRunProcess.stdout?.on("data", (chunk: Buffer) => { - logger.info(`[dart run] ${chunk.toString("utf8")}`); - }); - dartRunProcess.stderr?.on("data", (chunk: Buffer) => { - logger.error(`[dart run] ${chunk.toString("utf8")}`); - }); - - buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { - logger.info(`[build_runner] ${chunk.toString("utf8")}`); - }); - buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { - logger.error(`[build_runner] ${chunk.toString("utf8")}`); - }); - - // Return cleanup function - return Promise.resolve(async () => { - const killProcess = (proc: ChildProcess) => { - if (!proc.killed && proc.exitCode === null) { - proc.kill("SIGTERM"); - } - }; - - // Try graceful shutdown first - killProcess(dartRunProcess); - killProcess(buildRunnerProcess); - - // Wait a bit for graceful shutdown - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Force kill if still running - if (!dartRunProcess.killed && dartRunProcess.exitCode === null) { - dartRunProcess.kill("SIGKILL"); - } - if (!buildRunnerProcess.killed && buildRunnerProcess.exitCode === null) { - buildRunnerProcess.kill("SIGKILL"); - } - - // Wait for both processes to exit - await Promise.all([ - new Promise((resolve) => { - if (dartRunProcess.killed || dartRunProcess.exitCode !== null) { - resolve(); - } else { - dartRunProcess.once("exit", () => resolve()); - } - }), - new Promise((resolve) => { - if (buildRunnerProcess.killed || buildRunnerProcess.exitCode !== null) { - resolve(); - } else { - buildRunnerProcess.once("exit", () => resolve()); - } - }), - ]); - }); + // No-op: The FunctionsEmulator handles build_runner watch for hot reload + return Promise.resolve(() => Promise.resolve()); } async discoverBuild( diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 32c6843434e..e7e1a59b8a0 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -224,6 +224,7 @@ export class FunctionsEmulator implements EmulatorInstance { private staticBackends: EmulatableBackend[] = []; private dynamicBackends: EmulatableBackend[] = []; private watchers: chokidar.FSWatcher[] = []; + private buildRunnerProcesses: Map = new Map(); debugMode = false; @@ -475,6 +476,61 @@ export class FunctionsEmulator implements EmulatorInstance { return Promise.resolve(); } + /** + * Starts build_runner in watch mode for a Dart backend. + * This watches Dart source files and regenerates functions.yaml when they change. + */ + private startBuildRunnerWatch(backend: EmulatableBackend): void { + const bin = backend.bin || "dart"; + const codebase = backend.codebase; + + this.logger.logLabeled( + "BULLET", + "functions", + `Starting build_runner watch for Dart functions...`, + ); + + const buildRunnerProcess = spawn(bin, ["run", "build_runner", "watch", "--delete-conflicting-outputs"], { + cwd: backend.functionsDir, + stdio: ["ignore", "pipe", "pipe"], + }); + + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { + const output = chunk.toString("utf8").trim(); + if (output) { + this.logger.log("DEBUG", `[build_runner] ${output}`); + } + }); + + buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { + const output = chunk.toString("utf8").trim(); + if (output) { + this.logger.log("DEBUG", `[build_runner] ${output}`); + } + }); + + buildRunnerProcess.on("exit", (code) => { + if (code !== 0 && code !== null) { + this.logger.logLabeled( + "WARN", + "functions", + `build_runner exited with code ${code}. Hot reload may not work.`, + ); + } + this.buildRunnerProcesses.delete(codebase); + }); + + buildRunnerProcess.on("error", (err) => { + this.logger.logLabeled( + "WARN", + "functions", + `Failed to start build_runner: ${err.message}`, + ); + }); + + this.buildRunnerProcesses.set(codebase, buildRunnerProcess); + } + async connect(): Promise { for (const backend of this.staticBackends) { this.logger.logLabeled( @@ -483,15 +539,26 @@ export class FunctionsEmulator implements EmulatorInstance { `Watching "${backend.functionsDir}" for Cloud Functions...`, ); - // For Dart runtimes, watch only the YAML spec file since Dart handles its own hot reload + // First load triggers to discover the runtime type + await this.loadTriggers(backend, /* force= */ true); + + // Now we can check if it's Dart (runtime is set by loadTriggers -> discoverTriggers) const isDart = backend.runtime?.startsWith("dart"); - const watchPath = isDart - ? path.join(backend.functionsDir, ".dart_tool", "firebase", "functions.yaml") - : backend.functionsDir; + this.logger.log("DEBUG", `Runtime: ${backend.runtime}, isDart: ${isDart}`); - const watcher = chokidar.watch(watchPath, { + // For Dart runtimes, start build_runner watch to regenerate functions.yaml on source changes + if (isDart) { + this.startBuildRunnerWatch(backend); + } + const watcher = chokidar.watch(backend.functionsDir, { ignored: isDart - ? [] // For Dart, we're watching a specific file, so no ignore patterns needed + ? [ + /.+?[\\\/]\.dart_tool[\\\/].+?/, // Ignore .dart_tool (build outputs) + /.+?[\\\/]\.packages/, // Ignore .packages + /.+?[\\\/]build[\\\/].+?/, // Ignore build directory + /(^|[\/\\])\../, // Ignore hidden files + /.+\.log/, // Ignore log files + ] : [ /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules /(^|[\/\\])\../, // Ignore files which begin the a period @@ -504,13 +571,45 @@ export class FunctionsEmulator implements EmulatorInstance { this.watchers.push(watcher); - const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); - watcher.on("change", (filePath) => { - this.logger.log("DEBUG", `File ${filePath} changed, reloading triggers`); - return debouncedLoadTriggers(); + // Log when watcher is ready + watcher.on("ready", () => { + this.logger.log("DEBUG", `File watcher ready for ${backend.functionsDir}`); }); - await this.loadTriggers(backend, /* force= */ true); + if (isDart) { + // For Dart, reload triggers and refresh workers when source files change + const debouncedReload = debounce(async () => { + this.logger.logLabeled("BULLET", "functions", "Source file changed, reloading..."); + // Re-discover triggers in case function signatures changed (build_runner updates functions.yaml) + await this.loadTriggers(backend); + }, 1000); + watcher.on("change", (filePath) => { + this.logger.log("DEBUG", `Detected change: ${filePath}`); + return debouncedReload(); + }); + + // Also watch functions.yaml specifically - when build_runner regenerates it, + // we need to reload to discover new/changed function signatures + const functionsYamlPath = path.join(backend.functionsDir, ".dart_tool", "firebase", "functions.yaml"); + const yamlWatcher = chokidar.watch(functionsYamlPath, { persistent: true }); + this.watchers.push(yamlWatcher); + + const debouncedYamlReload = debounce(async () => { + this.logger.logLabeled("BULLET", "functions", "Function definitions changed, reloading..."); + await this.loadTriggers(backend); + }, 500); + yamlWatcher.on("change", () => { + this.logger.log("DEBUG", "functions.yaml changed"); + return debouncedYamlReload(); + }); + } else { + // For Node.js/Python, re-discover triggers on change + const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); + watcher.on("change", (filePath) => { + this.logger.log("DEBUG", `File ${filePath} changed, reloading triggers`); + return debouncedLoadTriggers(); + }); + } } await this.performPostLoadOperations(); return; @@ -537,6 +636,15 @@ export class FunctionsEmulator implements EmulatorInstance { } this.watchers = []; + // Stop all build_runner processes for Dart backends + for (const [codebase, proc] of this.buildRunnerProcesses) { + this.logger.log("DEBUG", `Stopping build_runner for ${codebase}`); + if (!proc.killed && proc.exitCode === null) { + proc.kill("SIGTERM"); + } + } + this.buildRunnerProcesses.clear(); + if (this.destroyServer) { await this.destroyServer(); } From c03fdbe6c6f328000073f120cfc6b5fede165da6 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 8 Jan 2026 15:56:02 +0100 Subject: [PATCH 03/21] fix: wait for build_runner initial build to complete --- src/emulator/functionsEmulator.ts | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index e7e1a59b8a0..cdeba8ebdd5 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -479,8 +479,9 @@ export class FunctionsEmulator implements EmulatorInstance { /** * Starts build_runner in watch mode for a Dart backend. * This watches Dart source files and regenerates functions.yaml when they change. + * Returns a promise that resolves when the initial build completes. */ - private startBuildRunnerWatch(backend: EmulatableBackend): void { + private startBuildRunnerWatch(backend: EmulatableBackend): Promise { const bin = backend.bin || "dart"; const codebase = backend.codebase; @@ -495,10 +496,31 @@ export class FunctionsEmulator implements EmulatorInstance { stdio: ["ignore", "pipe", "pipe"], }); + // Track whether initial build has completed + let initialBuildComplete = false; + let resolveInitialBuild: () => void; + let rejectInitialBuild: (err: Error) => void; + + const initialBuildPromise = new Promise((resolve, reject) => { + resolveInitialBuild = resolve; + rejectInitialBuild = reject; + }); + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { const output = chunk.toString("utf8").trim(); if (output) { this.logger.log("DEBUG", `[build_runner] ${output}`); + + // Check if initial build completed (look for "Succeeded after" message) + if (!initialBuildComplete && output.includes("Succeeded after")) { + initialBuildComplete = true; + this.logger.logLabeled( + "SUCCESS", + "functions", + `build_runner initial build completed`, + ); + resolveInitialBuild(); + } } }); @@ -516,6 +538,9 @@ export class FunctionsEmulator implements EmulatorInstance { "functions", `build_runner exited with code ${code}. Hot reload may not work.`, ); + if (!initialBuildComplete) { + rejectInitialBuild(new Error(`build_runner exited with code ${code}`)); + } } this.buildRunnerProcesses.delete(codebase); }); @@ -526,9 +551,14 @@ export class FunctionsEmulator implements EmulatorInstance { "functions", `Failed to start build_runner: ${err.message}`, ); + if (!initialBuildComplete) { + rejectInitialBuild(err); + } }); this.buildRunnerProcesses.set(codebase, buildRunnerProcess); + + return initialBuildPromise; } async connect(): Promise { @@ -547,8 +577,9 @@ export class FunctionsEmulator implements EmulatorInstance { this.logger.log("DEBUG", `Runtime: ${backend.runtime}, isDart: ${isDart}`); // For Dart runtimes, start build_runner watch to regenerate functions.yaml on source changes + // Wait for initial build to complete before continuing (to ensure functions.yaml exists) if (isDart) { - this.startBuildRunnerWatch(backend); + await this.startBuildRunnerWatch(backend); } const watcher = chokidar.watch(backend.functionsDir, { ignored: isDart From 98b784f7dea151ebed2e989c46bc1781070e039b Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 4 Feb 2026 09:30:33 +0100 Subject: [PATCH 04/21] fix rooting --- src/emulator/functionsEmulator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index cdeba8ebdd5..530fb5b4e54 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -2050,12 +2050,12 @@ export class FunctionsEmulator implements EmulatorInstance { "/", ); - // For Dart, include the function name in the path so the server can route + // For Dart, add a header with the function name for routing // Python's functions-framework uses FUNCTION_TARGET env var, but Dart loads all functions + // in a single shared process and routes based on the path or this header const isDart = record.backend.runtime?.startsWith("dart"); - if (isDart && path === "/") { - // Include function name in path for Dart routing - path = `/${trigger.entryPoint}`; + if (isDart) { + req.headers["x-firebase-function"] = trigger.entryPoint; } // We do this instead of just 302'ing because many HTTP clients don't respect 302s so it may From dca0f66fd47590b40ce43dd295ec75a661e63905 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 19 Feb 2026 10:19:49 +0100 Subject: [PATCH 05/21] emulator update --- src/deploy/functions/build.ts | 16 ++++++++++------ .../functions/runtimes/discovery/v1alpha1.ts | 5 +++++ src/emulator/functionsEmulator.ts | 12 ++++++------ src/emulator/functionsEmulatorShared.ts | 4 ++-- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index c786a22c907..2074a180590 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -207,9 +207,8 @@ export interface SecretEnvVar { export type MemoryOption = 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768; const allMemoryOptions: MemoryOption[] = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768]; -// Run is an automatic migration from gcfv2 and is not used on the wire. -export type FunctionsPlatform = Exclude; -export const AllFunctionsPlatforms: FunctionsPlatform[] = ["gcfv1", "gcfv2"]; +export type FunctionsPlatform = backend.FunctionsPlatform; +export const AllFunctionsPlatforms: FunctionsPlatform[] = ["gcfv1", "gcfv2", "run"]; export type VpcEgressSetting = backend.VpcEgressSettings; export const AllVpcEgressSettings: VpcEgressSetting[] = ["PRIVATE_RANGES_ONLY", "ALL_TRAFFIC"]; export type IngressSetting = backend.IngressSettings; @@ -223,13 +222,18 @@ export type Endpoint = Triggered & { // Defaults to false. If true, the function will be ignored during the deploy process. omit?: Field; - // Defaults to "gcfv2". "Run" will be an additional option defined later - platform?: "gcfv1" | "gcfv2"; + // Defaults to "gcfv2". "run" targets Cloud Run directly. + platform?: FunctionsPlatform; // Necessary for the GCF API to determine what code to load with the Functions Framework. - // Will become optional once "run" is supported as a platform entryPoint: string; + // Cloud Run container image URI (used when platform is "run"). + baseImageUri?: string; + + // Container entrypoint command (used when platform is "run"). + command?: string[]; + // The services account that this function should run as. // defaults to the GAE service account when a function is first created as a GCF gen 1 function. // Defaults to the compute service account when a function is first created as a GCF gen 2 function. diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 8afaeb1f016..d300a224689 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -68,6 +68,8 @@ export type WireEndpoint = build.Triggered & region?: build.ListField; entryPoint: string; platform?: build.FunctionsPlatform; + baseImageUri?: string; + command?: string[]; secretEnvironmentVariables?: Array | null; }; @@ -144,6 +146,8 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void { region: "List", platform: (platform) => build.AllFunctionsPlatforms.includes(platform), entryPoint: "string", + baseImageUri: "string", + command: "array", omit: "Field?", availableMemoryMb: (mem) => mem === null || isCEL(mem) || backend.isValidMemoryOption(mem), maxInstances: "Field?", @@ -413,6 +417,7 @@ function parseEndpointForBuild( entryPoint: ep.entryPoint, ...triggered, }; + copyIfPresent(parsed, ep, "baseImageUri", "command"); // Allow "serviceAccountEmail" but prefer "serviceAccount" if ("serviceAccountEmail" in (ep as any)) { parsed.serviceAccount = (ep as any).serviceAccountEmail; diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 530fb5b4e54..f5d576ffa6e 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -427,7 +427,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For Dart, include the function name in the path so the server can route // For other runtimes, use / as they use FUNCTION_TARGET env var const isDart = runtime?.startsWith("dart"); - const path = isDart ? `/${trigger.entryPoint}` : `/`; + const path = isDart ? `/${trigger.name}` : `/`; return new Promise((resolve, reject) => { const req = http.request( @@ -2017,7 +2017,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For callable functions we want to accept tokens without actually calling verifyIdToken const isCallable = trigger.labels && trigger.labels["deployment-callable"] === "true"; const authHeader = req.header("Authorization"); - if (authHeader && isCallable && trigger.platform !== "gcfv2") { + if (authHeader && isCallable && trigger.platform !== "gcfv2" && trigger.platform !== "run") { const token = this.tokenFromAuthHeader(authHeader); if (token) { const contextAuth = { @@ -2050,12 +2050,12 @@ export class FunctionsEmulator implements EmulatorInstance { "/", ); - // For Dart, add a header with the function name for routing - // Python's functions-framework uses FUNCTION_TARGET env var, but Dart loads all functions - // in a single shared process and routes based on the path or this header + // For Dart, route via path since all functions share a single process. + // The Dart server routes based on the first path segment (function name). + // Use trigger_name (e.g. "helloWorld") not trigger.id (e.g. "us-central1-helloWorld"). const isDart = record.backend.runtime?.startsWith("dart"); if (isDart) { - req.headers["x-firebase-function"] = trigger.entryPoint; + path = `/${req.params.trigger_name}${path === "/" ? "" : path}`; } // We do this instead of just 302'ing because many HTTP clients don't respect 302s so it may diff --git a/src/emulator/functionsEmulatorShared.ts b/src/emulator/functionsEmulatorShared.ts index 033fe4db335..fbb9ccaa083 100644 --- a/src/emulator/functionsEmulatorShared.ts +++ b/src/emulator/functionsEmulatorShared.ts @@ -477,13 +477,13 @@ export function getSignatureType(def: EmulatedTriggerDefinition): SignatureType if (def.httpsTrigger || def.blockingTrigger) { return "http"; } - if (def.platform === "gcfv2" && def.schedule) { + if ((def.platform === "gcfv2" || def.platform === "run") && def.schedule) { return "http"; } // TODO: As implemented, emulated CF3v1 functions cannot receive events in CloudEvent format, and emulated CF3v2 // functions cannot receive events in legacy format. This conflicts with our goal of introducing a 'compat' layer // that allows CF3v1 functions to target GCFv2 and vice versa. - return def.platform === "gcfv2" ? "cloudevent" : "event"; + return def.platform === "gcfv2" || def.platform === "run" ? "cloudevent" : "event"; } const LOCAL_SECRETS_FILE = ".secret.local"; From ea3ee57e1ac3574f847e0b510cf998428387b7a5 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 20 Feb 2026 09:25:28 +0100 Subject: [PATCH 06/21] update functions.yaml directory --- src/deploy/functions/runtimes/dart/index.ts | 4 ++-- src/emulator/functionsEmulator.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index ef54d628dc3..cba2a31fd0e 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -82,8 +82,8 @@ export class Delegate implements runtimes.RuntimeDelegate { _configValues: backend.RuntimeConfigValues, _envs: backend.EnvironmentVariables, ): Promise { - // Use file-based discovery from .dart_tool/firebase/functions.yaml - const yamlDir = path.join(this.sourceDir, ".dart_tool", "firebase"); + // Use file-based discovery from functions.yaml in the project root + const yamlDir = this.sourceDir; const yamlPath = path.join(yamlDir, "functions.yaml"); let discovered = await discovery.detectFromYaml(yamlDir, this.projectId, this.runtime); diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index f5d576ffa6e..288a50d7260 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -621,7 +621,7 @@ export class FunctionsEmulator implements EmulatorInstance { // Also watch functions.yaml specifically - when build_runner regenerates it, // we need to reload to discover new/changed function signatures - const functionsYamlPath = path.join(backend.functionsDir, ".dart_tool", "firebase", "functions.yaml"); + const functionsYamlPath = path.join(backend.functionsDir, "functions.yaml"); const yamlWatcher = chokidar.watch(functionsYamlPath, { persistent: true }); this.watchers.push(yamlWatcher); From 38c131a3a479917381edc6a17d73eca46a461800 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 24 Feb 2026 14:29:50 +0100 Subject: [PATCH 07/21] clean run on emulator --- src/deploy/functions/runtimes/dart/index.ts | 9 +++++++++ src/emulator/functionsEmulator.ts | 2 +- src/emulator/functionsEmulatorShared.ts | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index cba2a31fd0e..4925a7324a1 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -128,6 +128,15 @@ export class Delegate implements runtimes.RuntimeDelegate { } } + // Normalize "run" → "gcfv2" for emulator compatibility. + // The manifest emits "run" for Cloud Run deployment, but the emulator treats + // Dart functions identically to gcfv2 — routing is handled via runtime detection. + for (const ep of Object.values(discovered.endpoints)) { + if (ep.platform === "run") { + ep.platform = "gcfv2"; + } + } + return discovered; } } diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 288a50d7260..eaa4034dcb1 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -2017,7 +2017,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For callable functions we want to accept tokens without actually calling verifyIdToken const isCallable = trigger.labels && trigger.labels["deployment-callable"] === "true"; const authHeader = req.header("Authorization"); - if (authHeader && isCallable && trigger.platform !== "gcfv2" && trigger.platform !== "run") { + if (authHeader && isCallable && trigger.platform !== "gcfv2") { const token = this.tokenFromAuthHeader(authHeader); if (token) { const contextAuth = { diff --git a/src/emulator/functionsEmulatorShared.ts b/src/emulator/functionsEmulatorShared.ts index fbb9ccaa083..033fe4db335 100644 --- a/src/emulator/functionsEmulatorShared.ts +++ b/src/emulator/functionsEmulatorShared.ts @@ -477,13 +477,13 @@ export function getSignatureType(def: EmulatedTriggerDefinition): SignatureType if (def.httpsTrigger || def.blockingTrigger) { return "http"; } - if ((def.platform === "gcfv2" || def.platform === "run") && def.schedule) { + if (def.platform === "gcfv2" && def.schedule) { return "http"; } // TODO: As implemented, emulated CF3v1 functions cannot receive events in CloudEvent format, and emulated CF3v2 // functions cannot receive events in legacy format. This conflicts with our goal of introducing a 'compat' layer // that allows CF3v1 functions to target GCFv2 and vice versa. - return def.platform === "gcfv2" || def.platform === "run" ? "cloudevent" : "event"; + return def.platform === "gcfv2" ? "cloudevent" : "event"; } const LOCAL_SECRETS_FILE = ".secret.local"; From 0153b09b303766b27fff2c1153820f97639b5377 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 24 Feb 2026 14:57:44 +0100 Subject: [PATCH 08/21] clear project --- schema/firebase-config.json | 2 ++ src/deploy/functions/runtimes/dart/index.ts | 8 ++--- src/deploy/functions/runtimes/index.ts | 6 +++- src/emulator/functionsEmulator.ts | 34 ++++++++++----------- src/emulator/functionsRuntimeWorker.ts | 6 +++- src/init/features/functions/dart.ts | 7 +---- 6 files changed, 32 insertions(+), 31 deletions(-) diff --git a/schema/firebase-config.json b/schema/firebase-config.json index 32d3986fe9c..6fc32690c5e 100644 --- a/schema/firebase-config.json +++ b/schema/firebase-config.json @@ -4,6 +4,7 @@ "definitions": { "ActiveRuntime": { "enum": [ + "dart3", "nodejs18", "nodejs20", "nodejs22", @@ -912,6 +913,7 @@ }, "runtime": { "enum": [ + "dart3", "nodejs18", "nodejs20", "nodejs22", diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 4925a7324a1..6caff6bba15 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -62,9 +62,7 @@ export class Delegate implements runtimes.RuntimeDelegate { await fs.promises.access(pubspecYamlPath, fs.constants.R_OK); // TODO: could add more validation like checking for firebase_functions dependency } catch (err: any) { - throw new FirebaseError( - `Failed to read pubspec.yaml at ${pubspecYamlPath}: ${err.message}`, - ); + throw new FirebaseError(`Failed to read pubspec.yaml at ${pubspecYamlPath}: ${err.message}`); } } @@ -79,8 +77,8 @@ export class Delegate implements runtimes.RuntimeDelegate { } async discoverBuild( - _configValues: backend.RuntimeConfigValues, - _envs: backend.EnvironmentVariables, + _configValues: backend.RuntimeConfigValues, // eslint-disable-line @typescript-eslint/no-unused-vars + _envs: backend.EnvironmentVariables, // eslint-disable-line @typescript-eslint/no-unused-vars ): Promise { // Use file-based discovery from functions.yaml in the project root const yamlDir = this.sourceDir; diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index d8af6cdb89d..3e8105d9ff1 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -71,7 +71,11 @@ export interface DelegateContext { } type Factory = (context: DelegateContext) => Promise; -const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate, dart.tryCreateDelegate]; +const factories: Factory[] = [ + node.tryCreateDelegate, + python.tryCreateDelegate, + dart.tryCreateDelegate, +]; /** * Gets the delegate object responsible for discovering, building, and hosting diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index eaa4034dcb1..6245a1609e8 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -491,10 +491,14 @@ export class FunctionsEmulator implements EmulatorInstance { `Starting build_runner watch for Dart functions...`, ); - const buildRunnerProcess = spawn(bin, ["run", "build_runner", "watch", "--delete-conflicting-outputs"], { - cwd: backend.functionsDir, - stdio: ["ignore", "pipe", "pipe"], - }); + const buildRunnerProcess = spawn( + bin, + ["run", "build_runner", "watch", "--delete-conflicting-outputs"], + { + cwd: backend.functionsDir, + stdio: ["ignore", "pipe", "pipe"], + }, + ); // Track whether initial build has completed let initialBuildComplete = false; @@ -514,11 +518,7 @@ export class FunctionsEmulator implements EmulatorInstance { // Check if initial build completed (look for "Succeeded after" message) if (!initialBuildComplete && output.includes("Succeeded after")) { initialBuildComplete = true; - this.logger.logLabeled( - "SUCCESS", - "functions", - `build_runner initial build completed`, - ); + this.logger.logLabeled("SUCCESS", "functions", `build_runner initial build completed`); resolveInitialBuild(); } } @@ -546,11 +546,7 @@ export class FunctionsEmulator implements EmulatorInstance { }); buildRunnerProcess.on("error", (err) => { - this.logger.logLabeled( - "WARN", - "functions", - `Failed to start build_runner: ${err.message}`, - ); + this.logger.logLabeled("WARN", "functions", `Failed to start build_runner: ${err.message}`); if (!initialBuildComplete) { rejectInitialBuild(err); } @@ -626,7 +622,11 @@ export class FunctionsEmulator implements EmulatorInstance { this.watchers.push(yamlWatcher); const debouncedYamlReload = debounce(async () => { - this.logger.logLabeled("BULLET", "functions", "Function definitions changed, reloading..."); + this.logger.logLabeled( + "BULLET", + "functions", + "Function definitions changed, reloading...", + ); await this.loadTriggers(backend); }, 500); yamlWatcher.on("change", () => { @@ -1847,9 +1847,7 @@ export class FunctionsEmulator implements EmulatorInstance { delete dartEnvs.FUNCTION_SIGNATURE_TYPE; const bin = backend.bin || "dart"; - logger.debug( - `Starting Dart runtime with args: ${args.join(" ")} on port ${port}`, - ); + logger.debug(`Starting Dart runtime with args: ${args.join(" ")} on port ${port}`); const childProcess = spawn(bin, args, { cwd: backend.functionsDir, env: { diff --git a/src/emulator/functionsRuntimeWorker.ts b/src/emulator/functionsRuntimeWorker.ts index 9242da4b23a..3b57f9de3c2 100644 --- a/src/emulator/functionsRuntimeWorker.ts +++ b/src/emulator/functionsRuntimeWorker.ts @@ -438,7 +438,11 @@ export class RuntimeWorkerPool { return this.workers.get(this.getKey(triggerId, runtime)) || []; } - private setTriggerWorkers(triggerId: string | undefined, workers: Array, runtime?: string) { + private setTriggerWorkers( + triggerId: string | undefined, + workers: Array, + runtime?: string, + ) { this.workers.set(this.getKey(triggerId, runtime), workers); } diff --git a/src/init/features/functions/dart.ts b/src/init/features/functions/dart.ts index ce084a99da6..dd74415761b 100644 --- a/src/init/features/functions/dart.ts +++ b/src/init/features/functions/dart.ts @@ -6,7 +6,6 @@ import { latest } from "../../../deploy/functions/runtimes/supported"; // TODO(ehesp): Create these template files in templates/init/functions/dart/ // For now, we'll use basic templates // TODO(ehesp): Dont use relative path -// TODO(ehesp): Should const PUBSPEC_TEMPLATE = `name: functions description: Firebase Functions for Dart version: 1.0.0 @@ -57,10 +56,7 @@ firebase-debug.*.log * Create a Dart Firebase Functions project. */ export async function setup(setup: any, config: Config): Promise { - await config.askWriteProjectFile( - `${setup.functions.source}/pubspec.yaml`, - PUBSPEC_TEMPLATE, - ); + await config.askWriteProjectFile(`${setup.functions.source}/pubspec.yaml`, PUBSPEC_TEMPLATE); await config.askWriteProjectFile(`${setup.functions.source}/.gitignore`, GITIGNORE_TEMPLATE); await config.askWriteProjectFile(`${setup.functions.source}/lib/main.dart`, MAIN_TEMPLATE); @@ -84,4 +80,3 @@ export async function setup(setup: any, config: Config): Promise { }); } } - From efdce949651650c36b91d80b0c0af7fb5e773ada Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 24 Feb 2026 15:08:26 +0100 Subject: [PATCH 09/21] improve runtime checks --- src/deploy/functions/runtimes/supported/index.ts | 5 +++++ src/emulator/functionsEmulator.ts | 12 ++++++------ src/emulator/functionsRuntimeWorker.ts | 3 ++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/deploy/functions/runtimes/supported/index.ts b/src/deploy/functions/runtimes/supported/index.ts index c74e2968e6f..5640a56ecd1 100644 --- a/src/deploy/functions/runtimes/supported/index.ts +++ b/src/deploy/functions/runtimes/supported/index.ts @@ -17,6 +17,11 @@ export function runtimeIsLanguage( return runtime.startsWith(language); } +/** Check if an optional runtime string belongs to a given language. */ +export function isLanguageRuntime(runtime: string | undefined, language: Language): boolean { + return !!runtime && runtime.startsWith(language); +} + /** * Find the latest supported Runtime for a Language. */ diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 6245a1609e8..378465d3019 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -62,7 +62,7 @@ import { BlockingFunctionsConfig } from "../gcp/identityPlatform"; import { resolveBackend } from "../deploy/functions/build"; import { getCredentialsEnvironment, setEnvVarsForEmulators } from "./env"; import { runWithVirtualEnv } from "../functions/python"; -import { Runtime } from "../deploy/functions/runtimes/supported"; +import { isLanguageRuntime, Runtime } from "../deploy/functions/runtimes/supported"; import { ExtensionsEmulator } from "./extensionsEmulator"; const EVENT_INVOKE_GA4 = "functions_invoke"; // event name GA4 (alphanumertic) @@ -426,7 +426,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For Dart, include the function name in the path so the server can route // For other runtimes, use / as they use FUNCTION_TARGET env var - const isDart = runtime?.startsWith("dart"); + const isDart = isLanguageRuntime(runtime, "dart"); const path = isDart ? `/${trigger.name}` : `/`; return new Promise((resolve, reject) => { @@ -569,7 +569,7 @@ export class FunctionsEmulator implements EmulatorInstance { await this.loadTriggers(backend, /* force= */ true); // Now we can check if it's Dart (runtime is set by loadTriggers -> discoverTriggers) - const isDart = backend.runtime?.startsWith("dart"); + const isDart = isLanguageRuntime(backend.runtime, "dart"); this.logger.log("DEBUG", `Runtime: ${backend.runtime}, isDart: ${isDart}`); // For Dart runtimes, start build_runner watch to regenerate functions.yaml on source changes @@ -1884,9 +1884,9 @@ export class FunctionsEmulator implements EmulatorInstance { const secretEnvs = await this.resolveSecretEnvs(backend, trigger); let runtime; - if (backend.runtime!.startsWith("python")) { + if (isLanguageRuntime(backend.runtime, "python")) { runtime = await this.startPython(backend, { ...runtimeEnv, ...secretEnvs }); - } else if (backend.runtime!.startsWith("dart")) { + } else if (isLanguageRuntime(backend.runtime, "dart")) { runtime = await this.startDart(backend, { ...runtimeEnv, ...secretEnvs }); } else { runtime = await this.startNode(backend, { ...runtimeEnv, ...secretEnvs }); @@ -2051,7 +2051,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For Dart, route via path since all functions share a single process. // The Dart server routes based on the first path segment (function name). // Use trigger_name (e.g. "helloWorld") not trigger.id (e.g. "us-central1-helloWorld"). - const isDart = record.backend.runtime?.startsWith("dart"); + const isDart = isLanguageRuntime(record.backend.runtime, "dart"); if (isDart) { path = `/${req.params.trigger_name}${path === "/" ? "" : path}`; } diff --git a/src/emulator/functionsRuntimeWorker.ts b/src/emulator/functionsRuntimeWorker.ts index 3b57f9de3c2..ebba4af85f9 100644 --- a/src/emulator/functionsRuntimeWorker.ts +++ b/src/emulator/functionsRuntimeWorker.ts @@ -9,6 +9,7 @@ import { EmulatorLogger, ExtensionLogInfo } from "./emulatorLogger"; import { FirebaseError } from "../error"; import { Serializable } from "child_process"; import { getFunctionDiscoveryTimeout } from "../deploy/functions/runtimes/discovery"; +import { isLanguageRuntime } from "../deploy/functions/runtimes/supported"; type LogListener = (el: EmulatorLog) => any; @@ -302,7 +303,7 @@ export class RuntimeWorkerPool { } // For Dart, use a shared key so all functions in a codebase share the same process // Dart loads all functions and routes based on request path - if (runtime?.startsWith("dart")) { + if (isLanguageRuntime(runtime, "dart")) { return "~dart-shared~"; } return triggerId || "~diagnostic~"; From f0e89f86042aa3ea5490eb7cde09a83d2c8c77bd Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 24 Feb 2026 15:18:04 +0100 Subject: [PATCH 10/21] merge fixes --- .../.dart_tool/package_config.json | 232 +++++++++++++ .../.dart_tool/package_graph.json | 317 ++++++++++++++++++ .../crashlytics-flutter/.dart_tool/version | 1 + .../.flutter-plugins-dependencies | 1 + .../crashlytics-flutter/pubspec.lock | 282 ++++++++++++++++ .../functions/runtimes/discovery/v1alpha1.ts | 5 - 6 files changed, 833 insertions(+), 5 deletions(-) create mode 100644 scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_config.json create mode 100644 scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_graph.json create mode 100644 scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/version create mode 100644 scripts/agent-evals/templates/crashlytics-flutter/.flutter-plugins-dependencies create mode 100644 scripts/agent-evals/templates/crashlytics-flutter/pubspec.lock diff --git a/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_config.json b/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_config.json new file mode 100644 index 00000000000..51e48c2db75 --- /dev/null +++ b/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_config.json @@ -0,0 +1,232 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "_flutterfire_internals", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/_flutterfire_internals-1.3.35", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "async", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/async-2.13.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "characters", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/characters-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "clock", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/clock-1.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "collection", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "cupertino_icons", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/cupertino_icons-1.0.8", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "fake_async", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/fake_async-1.3.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "firebase_core", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core-2.32.0", + "packageUri": "lib/", + "languageVersion": "2.18" + }, + { + "name": "firebase_core_platform_interface", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core_platform_interface-5.4.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "firebase_core_web", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core_web-2.24.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "firebase_crashlytics", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/firebase_crashlytics-3.5.7", + "packageUri": "lib/", + "languageVersion": "2.18" + }, + { + "name": "firebase_crashlytics_platform_interface", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/firebase_crashlytics_platform_interface-3.6.35", + "packageUri": "lib/", + "languageVersion": "2.18" + }, + { + "name": "flutter", + "rootUri": "file:///Users/guillaume/fvm/versions/stable/packages/flutter", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "flutter_lints", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/flutter_lints-2.0.3", + "packageUri": "lib/", + "languageVersion": "2.19" + }, + { + "name": "flutter_test", + "rootUri": "file:///Users/guillaume/fvm/versions/stable/packages/flutter_test", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "flutter_web_plugins", + "rootUri": "file:///Users/guillaume/fvm/versions/stable/packages/flutter_web_plugins", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "leak_tracker", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_flutter_testing", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_testing", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "lints", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/lints-2.1.1", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "matcher", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/matcher-0.12.17", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "material_color_utilities", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1", + "packageUri": "lib/", + "languageVersion": "2.17" + }, + { + "name": "meta", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/meta-1.17.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "path", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "plugin_platform_interface", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/plugin_platform_interface-2.1.8", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "sky_engine", + "rootUri": "file:///Users/guillaume/fvm/versions/stable/bin/cache/pkg/sky_engine", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "source_span", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "string_scanner", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test_api", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/test_api-0.7.7", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vector_math", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/vector_math-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "vm_service", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/vm_service-15.0.2", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "web", + "rootUri": "file:///Users/guillaume/.pub-cache/hosted/pub.dev/web-1.1.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "crashlytics_flutter_example", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "2.18" + } + ], + "generator": "pub", + "generatorVersion": "3.10.0", + "flutterRoot": "file:///Users/guillaume/fvm/versions/stable", + "flutterVersion": "3.38.1", + "pubCache": "file:///Users/guillaume/.pub-cache" +} diff --git a/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_graph.json b/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_graph.json new file mode 100644 index 00000000000..e7b069a22de --- /dev/null +++ b/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/package_graph.json @@ -0,0 +1,317 @@ +{ + "roots": [ + "crashlytics_flutter_example" + ], + "packages": [ + { + "name": "crashlytics_flutter_example", + "version": "1.0.0+1", + "dependencies": [ + "cupertino_icons", + "firebase_core", + "firebase_crashlytics", + "flutter" + ], + "devDependencies": [ + "flutter_lints", + "flutter_test" + ] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "test_api", + "version": "0.7.7", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "meta", + "version": "1.17.0", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.17", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.11.1", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.0", + "dependencies": [] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "flutter_lints", + "version": "2.0.3", + "dependencies": [ + "lints" + ] + }, + { + "name": "async", + "version": "2.13.0", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "lints", + "version": "2.1.1", + "dependencies": [] + }, + { + "name": "cupertino_icons", + "version": "1.0.8", + "dependencies": [] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "vm_service", + "version": "15.0.2", + "dependencies": [] + }, + { + "name": "firebase_core", + "version": "2.32.0", + "dependencies": [ + "firebase_core_platform_interface", + "firebase_core_web", + "flutter", + "meta" + ] + }, + { + "name": "firebase_core_platform_interface", + "version": "5.4.2", + "dependencies": [ + "collection", + "flutter", + "flutter_test", + "meta", + "plugin_platform_interface" + ] + }, + { + "name": "plugin_platform_interface", + "version": "2.1.8", + "dependencies": [ + "meta" + ] + }, + { + "name": "flutter_web_plugins", + "version": "0.0.0", + "dependencies": [ + "flutter" + ] + }, + { + "name": "firebase_core_web", + "version": "2.24.0", + "dependencies": [ + "firebase_core_platform_interface", + "flutter", + "flutter_web_plugins", + "meta", + "web" + ] + }, + { + "name": "web", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "firebase_crashlytics", + "version": "3.5.7", + "dependencies": [ + "firebase_core", + "firebase_core_platform_interface", + "firebase_crashlytics_platform_interface", + "flutter", + "stack_trace" + ] + }, + { + "name": "firebase_crashlytics_platform_interface", + "version": "3.6.35", + "dependencies": [ + "_flutterfire_internals", + "collection", + "firebase_core", + "flutter", + "meta", + "plugin_platform_interface" + ] + }, + { + "name": "_flutterfire_internals", + "version": "1.3.35", + "dependencies": [ + "collection", + "firebase_core", + "firebase_core_platform_interface", + "flutter", + "meta" + ] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/version b/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/version new file mode 100644 index 00000000000..2f58982b313 --- /dev/null +++ b/scripts/agent-evals/templates/crashlytics-flutter/.dart_tool/version @@ -0,0 +1 @@ +3.38.1 \ No newline at end of file diff --git a/scripts/agent-evals/templates/crashlytics-flutter/.flutter-plugins-dependencies b/scripts/agent-evals/templates/crashlytics-flutter/.flutter-plugins-dependencies new file mode 100644 index 00000000000..7cf6acd078c --- /dev/null +++ b/scripts/agent-evals/templates/crashlytics-flutter/.flutter-plugins-dependencies @@ -0,0 +1 @@ +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"firebase_core","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core-2.32.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_crashlytics","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_crashlytics-3.5.7/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false}],"android":[{"name":"firebase_core","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core-2.32.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_crashlytics","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_crashlytics-3.5.7/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false}],"macos":[{"name":"firebase_core","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core-2.32.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_crashlytics","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_crashlytics-3.5.7/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false}],"linux":[],"windows":[{"name":"firebase_core","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core-2.32.0/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"firebase_core_web","path":"/Users/guillaume/.pub-cache/hosted/pub.dev/firebase_core_web-2.24.0/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"firebase_crashlytics","dependencies":["firebase_core"]}],"date_created":"2026-02-24 15:14:25.074245","version":"3.38.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/scripts/agent-evals/templates/crashlytics-flutter/pubspec.lock b/scripts/agent-evals/templates/crashlytics-flutter/pubspec.lock new file mode 100644 index 00000000000..c330e521fb5 --- /dev/null +++ b/scripts/agent-evals/templates/crashlytics-flutter/pubspec.lock @@ -0,0 +1,282 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "37a42d06068e2fe3deddb2da079a8c4d105f241225ba27b7122b37e9865fd8f7" + url: "https://pub.dev" + source: hosted + version: "1.3.35" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "26de145bb9688a90962faec6f838247377b0b0d32cc0abecd9a4e43525fc856c" + url: "https://pub.dev" + source: hosted + version: "2.32.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "8bcfad6d7033f5ea951d15b867622a824b13812178bfec0c779b9d81de011bbb" + url: "https://pub.dev" + source: hosted + version: "5.4.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: eb3afccfc452b2b2075acbe0c4b27de62dd596802b4e5e19869c1e926cbb20b3 + url: "https://pub.dev" + source: hosted + version: "2.24.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "9897c01efaa950d2f6da8317d12452749a74dc45f33b46390a14cfe28067f271" + url: "https://pub.dev" + source: hosted + version: "3.5.7" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "16a71e08fbf6e00382816e1b13397898c29a54fa0ad969c2c2a3b82a704877f0" + url: "https://pub.dev" + source: hosted + version: "3.6.35" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.22.0" diff --git a/src/deploy/functions/runtimes/discovery/v1alpha1.ts b/src/deploy/functions/runtimes/discovery/v1alpha1.ts index 009251486b5..8f414a2eabb 100644 --- a/src/deploy/functions/runtimes/discovery/v1alpha1.ts +++ b/src/deploy/functions/runtimes/discovery/v1alpha1.ts @@ -69,8 +69,6 @@ export type WireEndpoint = build.Triggered & region?: build.ListField; entryPoint: string; platform?: build.FunctionsPlatform; - baseImageUri?: string; - command?: string[]; secretEnvironmentVariables?: Array | null; baseImageUri?: string; command?: string[]; @@ -150,8 +148,6 @@ function assertBuildEndpoint(ep: WireEndpoint, id: string): void { region: "List", platform: (platform) => build.AllFunctionsPlatforms.includes(platform), entryPoint: "string", - baseImageUri: "string", - command: "array", omit: "Field?", availableMemoryMb: (mem) => mem === null || isCEL(mem) || backend.isValidMemoryOption(mem), maxInstances: "Field?", @@ -441,7 +437,6 @@ function parseEndpointForBuild( entryPoint: ep.entryPoint, ...triggered, }; - copyIfPresent(parsed, ep, "baseImageUri", "command"); // Allow "serviceAccountEmail" but prefer "serviceAccount" if ("serviceAccountEmail" in (ep as any)) { parsed.serviceAccount = (ep as any).serviceAccountEmail; From bfe23a9af72894a3ca080459af3ba5c492daea2c Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Tue, 24 Feb 2026 15:24:02 +0100 Subject: [PATCH 11/21] clean --- src/deploy/functions/runtimes/dart/index.ts | 97 +++++++--- src/emulator/functionsEmulator.ts | 185 ++++---------------- src/init/features/functions/dart.ts | 6 +- 3 files changed, 112 insertions(+), 176 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 6caff6bba15..260d2ed2937 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { promisify } from "util"; import * as spawn from "cross-spawn"; +import { ChildProcess } from "child_process"; import * as runtimes from ".."; import * as backend from "../../backend"; @@ -40,27 +41,20 @@ export async function tryCreateDelegate( export class Delegate implements runtimes.RuntimeDelegate { public readonly language = "dart"; + public readonly bin = "dart"; + + private buildRunnerProcess: ChildProcess | null = null; + constructor( private readonly projectId: string, private readonly sourceDir: string, public readonly runtime: supported.Runtime & supported.RuntimeOf<"dart">, ) {} - private _bin = ""; - - get bin(): string { - if (this._bin === "") { - this._bin = "dart"; - } - return this._bin; - } - async validate(): Promise { - // Basic validation: check that pubspec.yaml exists and is readable const pubspecYamlPath = path.join(this.sourceDir, "pubspec.yaml"); try { await fs.promises.access(pubspecYamlPath, fs.constants.R_OK); - // TODO: could add more validation like checking for firebase_functions dependency } catch (err: any) { throw new FirebaseError(`Failed to read pubspec.yaml at ${pubspecYamlPath}: ${err.message}`); } @@ -71,29 +65,95 @@ export class Delegate implements runtimes.RuntimeDelegate { return Promise.resolve(); } - watch(): Promise<() => Promise> { - // No-op: The FunctionsEmulator handles build_runner watch for hot reload - return Promise.resolve(() => Promise.resolve()); + /** + * Start build_runner in watch mode for hot reload. + * Returns a cleanup function that stops the build_runner process. + * The returned promise resolves once the initial build completes. + */ + async watch(): Promise<() => Promise> { + logger.debug("Starting build_runner watch for Dart functions..."); + + const buildRunnerProcess = spawn( + this.bin, + ["run", "build_runner", "watch", "--delete-conflicting-outputs"], + { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + this.buildRunnerProcess = buildRunnerProcess; + + let initialBuildComplete = false; + let resolveInitialBuild: () => void; + let rejectInitialBuild: (err: Error) => void; + + const initialBuildPromise = new Promise((resolve, reject) => { + resolveInitialBuild = resolve; + rejectInitialBuild = reject; + }); + + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { + const output = chunk.toString("utf8").trim(); + if (output) { + logger.debug(`[build_runner] ${output}`); + if (!initialBuildComplete && output.includes("Succeeded after")) { + initialBuildComplete = true; + logger.debug("build_runner initial build completed"); + resolveInitialBuild(); + } + } + }); + + buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { + const output = chunk.toString("utf8").trim(); + if (output) { + logger.debug(`[build_runner] ${output}`); + } + }); + + buildRunnerProcess.on("exit", (code) => { + if (code !== 0 && code !== null) { + logger.debug(`build_runner exited with code ${code}. Hot reload may not work.`); + if (!initialBuildComplete) { + rejectInitialBuild(new Error(`build_runner exited with code ${code}`)); + } + } + this.buildRunnerProcess = null; + }); + + buildRunnerProcess.on("error", (err) => { + logger.debug(`Failed to start build_runner: ${err.message}`); + if (!initialBuildComplete) { + rejectInitialBuild(err); + } + }); + + await initialBuildPromise; + + // Return cleanup function + return async () => { + if (this.buildRunnerProcess && !this.buildRunnerProcess.killed) { + this.buildRunnerProcess.kill("SIGTERM"); + this.buildRunnerProcess = null; + } + }; } async discoverBuild( _configValues: backend.RuntimeConfigValues, // eslint-disable-line @typescript-eslint/no-unused-vars _envs: backend.EnvironmentVariables, // eslint-disable-line @typescript-eslint/no-unused-vars ): Promise { - // Use file-based discovery from functions.yaml in the project root const yamlDir = this.sourceDir; const yamlPath = path.join(yamlDir, "functions.yaml"); let discovered = await discovery.detectFromYaml(yamlDir, this.projectId, this.runtime); if (!discovered) { - // If the file doesn't exist yet, run build_runner to generate it logger.debug("functions.yaml not found, running build_runner to generate it..."); const buildRunnerProcess = spawn(this.bin, ["run", "build_runner", "build"], { cwd: this.sourceDir, stdio: ["ignore", "pipe", "pipe"], }); - // Log build_runner output buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { logger.debug(`[build_runner] ${chunk.toString("utf8")}`); }); @@ -116,7 +176,6 @@ export class Delegate implements runtimes.RuntimeDelegate { buildRunnerProcess.on("error", reject); }); - // Try to discover again after build_runner completes discovered = await discovery.detectFromYaml(yamlDir, this.projectId, this.runtime); if (!discovered) { throw new FirebaseError( @@ -127,8 +186,6 @@ export class Delegate implements runtimes.RuntimeDelegate { } // Normalize "run" → "gcfv2" for emulator compatibility. - // The manifest emits "run" for Cloud Run deployment, but the emulator treats - // Dart functions identically to gcfv2 — routing is handled via runtime detection. for (const ep of Object.values(discovered.endpoints)) { if (ep.platform === "run") { ep.platform = "gcfv2"; diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 22af92ca3c9..b722f94b2fd 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -222,7 +222,7 @@ export class FunctionsEmulator implements EmulatorInstance { private staticBackends: EmulatableBackend[] = []; private dynamicBackends: EmulatableBackend[] = []; private watchers: chokidar.FSWatcher[] = []; - private buildRunnerProcesses: Map = new Map(); + private watchCleanups: Array<() => Promise> = []; debugMode = false; @@ -474,87 +474,6 @@ export class FunctionsEmulator implements EmulatorInstance { return Promise.resolve(); } - /** - * Starts build_runner in watch mode for a Dart backend. - * This watches Dart source files and regenerates functions.yaml when they change. - * Returns a promise that resolves when the initial build completes. - */ - private startBuildRunnerWatch(backend: EmulatableBackend): Promise { - const bin = backend.bin || "dart"; - const codebase = backend.codebase; - - this.logger.logLabeled( - "BULLET", - "functions", - `Starting build_runner watch for Dart functions...`, - ); - - const buildRunnerProcess = spawn( - bin, - ["run", "build_runner", "watch", "--delete-conflicting-outputs"], - { - cwd: backend.functionsDir, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - // Track whether initial build has completed - let initialBuildComplete = false; - let resolveInitialBuild: () => void; - let rejectInitialBuild: (err: Error) => void; - - const initialBuildPromise = new Promise((resolve, reject) => { - resolveInitialBuild = resolve; - rejectInitialBuild = reject; - }); - - buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { - const output = chunk.toString("utf8").trim(); - if (output) { - this.logger.log("DEBUG", `[build_runner] ${output}`); - - // Check if initial build completed (look for "Succeeded after" message) - if (!initialBuildComplete && output.includes("Succeeded after")) { - initialBuildComplete = true; - this.logger.logLabeled("SUCCESS", "functions", `build_runner initial build completed`); - resolveInitialBuild(); - } - } - }); - - buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { - const output = chunk.toString("utf8").trim(); - if (output) { - this.logger.log("DEBUG", `[build_runner] ${output}`); - } - }); - - buildRunnerProcess.on("exit", (code) => { - if (code !== 0 && code !== null) { - this.logger.logLabeled( - "WARN", - "functions", - `build_runner exited with code ${code}. Hot reload may not work.`, - ); - if (!initialBuildComplete) { - rejectInitialBuild(new Error(`build_runner exited with code ${code}`)); - } - } - this.buildRunnerProcesses.delete(codebase); - }); - - buildRunnerProcess.on("error", (err) => { - this.logger.logLabeled("WARN", "functions", `Failed to start build_runner: ${err.message}`); - if (!initialBuildComplete) { - rejectInitialBuild(err); - } - }); - - this.buildRunnerProcesses.set(codebase, buildRunnerProcess); - - return initialBuildPromise; - } - async connect(): Promise { for (const backend of this.staticBackends) { this.logger.logLabeled( @@ -566,79 +485,46 @@ export class FunctionsEmulator implements EmulatorInstance { // First load triggers to discover the runtime type await this.loadTriggers(backend, /* force= */ true); - // Now we can check if it's Dart (runtime is set by loadTriggers -> discoverTriggers) const isDart = isLanguageRuntime(backend.runtime, "dart"); - this.logger.log("DEBUG", `Runtime: ${backend.runtime}, isDart: ${isDart}`); - // For Dart runtimes, start build_runner watch to regenerate functions.yaml on source changes - // Wait for initial build to complete before continuing (to ensure functions.yaml exists) + // For Dart, start build_runner watch via the delegate's watch() method. + // This waits for the initial build to complete before continuing. if (isDart) { - await this.startBuildRunnerWatch(backend); + const runtimeDelegateContext: runtimes.DelegateContext = { + projectId: this.args.projectId, + projectDir: this.args.projectDir, + sourceDir: backend.functionsDir, + runtime: backend.runtime, + }; + const delegate = await runtimes.getRuntimeDelegate(runtimeDelegateContext); + this.logger.logLabeled( + "BULLET", + "functions", + `Starting build_runner watch for Dart functions...`, + ); + const cleanup = await delegate.watch(); + this.watchCleanups.push(cleanup); + this.logger.logLabeled("SUCCESS", "functions", `build_runner initial build completed`); } + const watcher = chokidar.watch(backend.functionsDir, { - ignored: isDart - ? [ - /.+?[\\\/]\.dart_tool[\\\/].+?/, // Ignore .dart_tool (build outputs) - /.+?[\\\/]\.packages/, // Ignore .packages - /.+?[\\\/]build[\\\/].+?/, // Ignore build directory - /(^|[\/\\])\../, // Ignore hidden files - /.+\.log/, // Ignore log files - ] - : [ - /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules - /(^|[\/\\])\../, // Ignore files which begin the a period - /.+\.log/, // Ignore files which have a .log extension - /.+?[\\\/]venv[\\\/].+?/, // Ignore site-packages in venv - ...(backend.ignore?.map((i) => `**/${i}`) ?? []), - ], + ignored: [ + /(^|[\/\\])\../, // Ignore hidden files/dirs (covers .dart_tool, .git, etc.) + /.+\.log/, // Ignore log files + /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules + /.+?[\\\/]venv[\\\/].+?/, // Ignore venv + ...(backend.ignore?.map((i) => `**/${i}`) ?? []), + ], persistent: true, }); this.watchers.push(watcher); - // Log when watcher is ready - watcher.on("ready", () => { - this.logger.log("DEBUG", `File watcher ready for ${backend.functionsDir}`); + const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); + watcher.on("change", (filePath) => { + this.logger.log("DEBUG", `File ${filePath} changed, reloading triggers`); + return debouncedLoadTriggers(); }); - - if (isDart) { - // For Dart, reload triggers and refresh workers when source files change - const debouncedReload = debounce(async () => { - this.logger.logLabeled("BULLET", "functions", "Source file changed, reloading..."); - // Re-discover triggers in case function signatures changed (build_runner updates functions.yaml) - await this.loadTriggers(backend); - }, 1000); - watcher.on("change", (filePath) => { - this.logger.log("DEBUG", `Detected change: ${filePath}`); - return debouncedReload(); - }); - - // Also watch functions.yaml specifically - when build_runner regenerates it, - // we need to reload to discover new/changed function signatures - const functionsYamlPath = path.join(backend.functionsDir, "functions.yaml"); - const yamlWatcher = chokidar.watch(functionsYamlPath, { persistent: true }); - this.watchers.push(yamlWatcher); - - const debouncedYamlReload = debounce(async () => { - this.logger.logLabeled( - "BULLET", - "functions", - "Function definitions changed, reloading...", - ); - await this.loadTriggers(backend); - }, 500); - yamlWatcher.on("change", () => { - this.logger.log("DEBUG", "functions.yaml changed"); - return debouncedYamlReload(); - }); - } else { - // For Node.js/Python, re-discover triggers on change - const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); - watcher.on("change", (filePath) => { - this.logger.log("DEBUG", `File ${filePath} changed, reloading triggers`); - return debouncedLoadTriggers(); - }); - } } await this.performPostLoadOperations(); return; @@ -665,14 +551,11 @@ export class FunctionsEmulator implements EmulatorInstance { } this.watchers = []; - // Stop all build_runner processes for Dart backends - for (const [codebase, proc] of this.buildRunnerProcesses) { - this.logger.log("DEBUG", `Stopping build_runner for ${codebase}`); - if (!proc.killed && proc.exitCode === null) { - proc.kill("SIGTERM"); - } + // Stop delegate watch processes (e.g., build_runner for Dart) + for (const cleanup of this.watchCleanups) { + await cleanup(); } - this.buildRunnerProcesses.clear(); + this.watchCleanups = []; if (this.destroyServer) { await this.destroyServer(); diff --git a/src/init/features/functions/dart.ts b/src/init/features/functions/dart.ts index dd74415761b..7841229af07 100644 --- a/src/init/features/functions/dart.ts +++ b/src/init/features/functions/dart.ts @@ -4,8 +4,7 @@ import { confirm } from "../../../prompt"; import { latest } from "../../../deploy/functions/runtimes/supported"; // TODO(ehesp): Create these template files in templates/init/functions/dart/ -// For now, we'll use basic templates -// TODO(ehesp): Dont use relative path +// TODO(ehesp): Dont use relative path for firebase_functions const PUBSPEC_TEMPLATE = `name: functions description: Firebase Functions for Dart version: 1.0.0 @@ -16,14 +15,12 @@ environment: dependencies: firebase_functions: path: ../ - shelf: dev_dependencies: build_runner: ^2.4.0 `; const MAIN_TEMPLATE = `import 'package:firebase_functions/firebase_functions.dart'; -import 'package:shelf/shelf.dart'; void main(List args) { fireUp(args, (firebase) { @@ -40,7 +37,6 @@ void main(List args) { const GITIGNORE_TEMPLATE = `.dart_tool/ build/ -.dart_tool/ *.dart.js *.info.json *.js From 372c7a5d5031bdc8547ee2236b723c778f32d662 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 25 Feb 2026 09:02:52 +0100 Subject: [PATCH 12/21] Fixing feedback from Gemini --- src/deploy/functions/runtimes/dart/index.ts | 12 ++++-- src/emulator/functionsEmulator.ts | 12 +++--- src/emulator/functionsRuntimeWorker.ts | 4 +- src/init/features/functions/dart.ts | 48 ++------------------- templates/init/functions/dart/_gitignore | 11 +++++ templates/init/functions/dart/main.dart | 13 ++++++ templates/init/functions/dart/pubspec.yaml | 14 ++++++ 7 files changed, 58 insertions(+), 56 deletions(-) create mode 100644 templates/init/functions/dart/_gitignore create mode 100644 templates/init/functions/dart/main.dart create mode 100644 templates/init/functions/dart/pubspec.yaml diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 260d2ed2937..ba68c915b62 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -113,16 +113,22 @@ export class Delegate implements runtimes.RuntimeDelegate { buildRunnerProcess.on("exit", (code) => { if (code !== 0 && code !== null) { - logger.debug(`build_runner exited with code ${code}. Hot reload may not work.`); + logger.debug(`build_runner exited with code ${code}. Initial build failed.`); if (!initialBuildComplete) { - rejectInitialBuild(new Error(`build_runner exited with code ${code}`)); + rejectInitialBuild( + new FirebaseError( + `build_runner exited with code ${code}. Your Dart functions may not be deployed or emulated correctly.`, + ), + ); } } this.buildRunnerProcess = null; }); buildRunnerProcess.on("error", (err) => { - logger.debug(`Failed to start build_runner: ${err.message}`); + logger.debug( + `Failed to start build_runner: ${err.message}. Your Dart functions may not be deployed or emulated correctly.`, + ); if (!initialBuildComplete) { rejectInitialBuild(err); } diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index b722f94b2fd..7afc4d7dc25 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -400,8 +400,7 @@ export class FunctionsEmulator implements EmulatorInstance { async sendRequest(trigger: EmulatedTriggerDefinition, body?: any) { const record = this.getTriggerRecordByKey(this.getTriggerKey(trigger)); const pool = this.workerPools[record.backend.codebase]; - const runtime = record.backend.runtime; - if (!pool.readyForWork(trigger.id, runtime)) { + if (!pool.readyForWork(trigger.id, record.backend.runtime)) { try { await this.startRuntime(record.backend, trigger); } catch (e: any) { @@ -409,7 +408,7 @@ export class FunctionsEmulator implements EmulatorInstance { return; } } - const worker = pool.getIdleWorker(trigger.id, runtime)!; + const worker = pool.getIdleWorker(trigger.id, record.backend.runtime)!; if (this.debugMode) { await worker.sendDebugMsg({ functionTarget: trigger.entryPoint, @@ -424,7 +423,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For Dart, include the function name in the path so the server can route // For other runtimes, use / as they use FUNCTION_TARGET env var - const isDart = isLanguageRuntime(runtime, "dart"); + const isDart = isLanguageRuntime(record.backend.runtime, "dart"); const path = isDart ? `/${trigger.name}` : `/`; return new Promise((resolve, reject) => { @@ -1887,8 +1886,7 @@ export class FunctionsEmulator implements EmulatorInstance { this.logger.log("DEBUG", `[functions] Got req.url=${req.url}, mapping to path=${path}`); const pool = this.workerPools[record.backend.codebase]; - const runtime = record.backend.runtime; - if (!pool.readyForWork(trigger.id, runtime)) { + if (!pool.readyForWork(trigger.id, record.backend.runtime)) { try { await this.startRuntime(record.backend, trigger); } catch (e: any) { @@ -1917,7 +1915,7 @@ export class FunctionsEmulator implements EmulatorInstance { res as http.ServerResponse, reqBody, debugBundle, - runtime, + record.backend.runtime, ); } } diff --git a/src/emulator/functionsRuntimeWorker.ts b/src/emulator/functionsRuntimeWorker.ts index ebba4af85f9..0593dc5a2a0 100644 --- a/src/emulator/functionsRuntimeWorker.ts +++ b/src/emulator/functionsRuntimeWorker.ts @@ -301,8 +301,8 @@ export class RuntimeWorkerPool { if (this.mode === FunctionsExecutionMode.SEQUENTIAL) { return "~shared~"; } - // For Dart, use a shared key so all functions in a codebase share the same process - // Dart loads all functions and routes based on request path + // For Dart, use a shared key so all functions in a codebase share the same worker process. + // Dart loads all functions into a single process and routes based on request path. if (isLanguageRuntime(runtime, "dart")) { return "~dart-shared~"; } diff --git a/src/init/features/functions/dart.ts b/src/init/features/functions/dart.ts index 7841229af07..d8f232372ee 100644 --- a/src/init/features/functions/dart.ts +++ b/src/init/features/functions/dart.ts @@ -2,51 +2,11 @@ import * as spawn from "cross-spawn"; import { Config } from "../../../config"; import { confirm } from "../../../prompt"; import { latest } from "../../../deploy/functions/runtimes/supported"; +import { readTemplateSync } from "../../../templates"; -// TODO(ehesp): Create these template files in templates/init/functions/dart/ -// TODO(ehesp): Dont use relative path for firebase_functions -const PUBSPEC_TEMPLATE = `name: functions -description: Firebase Functions for Dart -version: 1.0.0 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - firebase_functions: - path: ../ - -dev_dependencies: - build_runner: ^2.4.0 -`; - -const MAIN_TEMPLATE = `import 'package:firebase_functions/firebase_functions.dart'; - -void main(List args) { - fireUp(args, (firebase) { - - firebase.https.onRequest( - name: 'helloWorld', - options: const HttpsOptions(cors: Cors(['*'])), - (request) async { - return Response(200, body: 'Hello from Dart Functions!'); - }); - }); -} -`; - -const GITIGNORE_TEMPLATE = `.dart_tool/ -build/ -*.dart.js -*.info.json -*.js -*.js.map -*.js.deps -*.js.symbols -firebase-debug.log -firebase-debug.*.log -*.local -`; +const PUBSPEC_TEMPLATE = readTemplateSync("init/functions/dart/pubspec.yaml"); +const MAIN_TEMPLATE = readTemplateSync("init/functions/dart/main.dart"); +const GITIGNORE_TEMPLATE = readTemplateSync("init/functions/dart/_gitignore"); /** * Create a Dart Firebase Functions project. diff --git a/templates/init/functions/dart/_gitignore b/templates/init/functions/dart/_gitignore new file mode 100644 index 00000000000..fb25e1562cd --- /dev/null +++ b/templates/init/functions/dart/_gitignore @@ -0,0 +1,11 @@ +.dart_tool/ +build/ +*.dart.js +*.info.json +*.js +*.js.map +*.js.deps +*.js.symbols +firebase-debug.log +firebase-debug.*.log +*.local diff --git a/templates/init/functions/dart/main.dart b/templates/init/functions/dart/main.dart new file mode 100644 index 00000000000..6179e7066ae --- /dev/null +++ b/templates/init/functions/dart/main.dart @@ -0,0 +1,13 @@ +import 'package:firebase_functions/firebase_functions.dart'; + +void main(List args) { + fireUp(args, (firebase) { + + firebase.https.onRequest( + name: 'helloWorld', + options: const HttpsOptions(cors: Cors(['*'])), + (request) async { + return Response(200, body: 'Hello from Dart Functions!'); + }); + }); +} diff --git a/templates/init/functions/dart/pubspec.yaml b/templates/init/functions/dart/pubspec.yaml new file mode 100644 index 00000000000..75085077f8a --- /dev/null +++ b/templates/init/functions/dart/pubspec.yaml @@ -0,0 +1,14 @@ +name: functions +description: Firebase Functions for Dart +version: 1.0.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + # TODO(ehesp): Replace with published package version once available on pub.dev + firebase_functions: + path: ../ + +dev_dependencies: + build_runner: ^2.4.0 From 7fb8b3461c693cafdee62be90f136188dd05cc0e Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 25 Feb 2026 09:23:34 +0100 Subject: [PATCH 13/21] compile before deploy --- src/deploy/functions/runtimes/dart/index.ts | 77 ++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index ba68c915b62..7b0b9abe449 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -10,6 +10,7 @@ import * as discovery from "../discovery"; import * as supported from "../supported"; import { logger } from "../../../../logger"; import { FirebaseError } from "../../../../error"; +import { logLabeledBullet } from "../../../../utils"; import { Build } from "../../build"; /** @@ -61,8 +62,80 @@ export class Delegate implements runtimes.RuntimeDelegate { } async build(): Promise { - // No-op: build_runner handles building - return Promise.resolve(); + // Run build_runner to generate up-to-date functions.yaml + logLabeledBullet("functions", "running build_runner..."); + + const buildRunnerProcess = spawn( + this.bin, + ["run", "build_runner", "build", "--delete-conflicting-outputs"], + { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { + logger.debug(`[build_runner] ${chunk.toString("utf8").trim()}`); + }); + buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => { + logger.debug(`[build_runner] ${chunk.toString("utf8").trim()}`); + }); + + await new Promise((resolve, reject) => { + buildRunnerProcess.on("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + } else { + reject( + new FirebaseError( + `build_runner failed with exit code ${code}. ` + + `Make sure your Dart project is properly configured.`, + ), + ); + } + }); + buildRunnerProcess.on("error", reject); + }); + + // Compile Dart to native Linux executable + const binDir = path.join(this.sourceDir, "bin"); + await fs.promises.mkdir(binDir, { recursive: true }); + + logLabeledBullet("functions", "compiling Dart to native executable..."); + + const compileProcess = spawn( + this.bin, + ["compile", "exe", "lib/main.dart", "-o", "bin/server", "--target-os=linux"], + { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + compileProcess.stdout?.on("data", (chunk: Buffer) => { + logger.debug(`[dart compile] ${chunk.toString("utf8").trim()}`); + }); + compileProcess.stderr?.on("data", (chunk: Buffer) => { + logger.debug(`[dart compile] ${chunk.toString("utf8").trim()}`); + }); + + await new Promise((resolve, reject) => { + compileProcess.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 lib/main.dart`, + ), + ); + } + }); + compileProcess.on("error", reject); + }); + + logLabeledBullet("functions", "Dart compilation complete."); } /** From 63d84a83e2252afb80a8560c217bd35bec9c7a25 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 25 Feb 2026 14:13:04 +0100 Subject: [PATCH 14/21] remove placeholder runtime --- src/deploy/functions/runtimes/dart.ts | 56 --------------------------- 1 file changed, 56 deletions(-) delete mode 100644 src/deploy/functions/runtimes/dart.ts diff --git a/src/deploy/functions/runtimes/dart.ts b/src/deploy/functions/runtimes/dart.ts deleted file mode 100644 index bcc90b637c0..00000000000 --- a/src/deploy/functions/runtimes/dart.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as fs from "fs-extra"; -import * as path from "path"; -import * as yaml from "js-yaml"; -import { DelegateContext, RuntimeDelegate } from "./index"; -import * as discovery from "./discovery"; - -// TODO: Temporary file for testing no build deploy. Remove this file after Invertase prepare phase is merged -/** - * Create a runtime delegate for the Dart runtime, if applicable. - * @param context runtimes.DelegateContext - * @return Delegate Dart runtime delegate - */ -export async function tryCreateDelegate( - context: DelegateContext, -): Promise { - const yamlPath = path.join(context.sourceDir, "functions.yaml"); - if (!(await fs.pathExists(yamlPath))) { - return undefined; - } - - // If runtime is specified, use it. Otherwise default to "dart3". - // "dart" is often used as a generic alias, map it to "dart3" - const runtime = context.runtime || "dart3"; - - return { - language: "dart", - runtime: runtime, - bin: "", // No bin needed for no-build - validate: async () => { - // Basic validation that the file is parseable - try { - const content = await fs.readFile(yamlPath, "utf8"); - yaml.load(content); - } catch (e: any) { - throw new Error(`Failed to parse functions.yaml: ${e.message}`); - } - }, - build: async () => { - // No-op for no-build - return Promise.resolve(); - }, - watch: async () => { - return Promise.resolve(async () => { - // No-op - }); - }, - discoverBuild: async () => { - const build = await discovery.detectFromYaml(context.sourceDir, context.projectId, runtime); - if (!build) { - // This should not happen because we checked for existence in tryCreateDelegate - throw new Error("Could not find functions.yaml"); - } - return build; - }, - }; -} From f4ab856624a10ef27723606f791ba660dc314b97 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 25 Feb 2026 14:28:45 +0100 Subject: [PATCH 15/21] cross compiling --- src/deploy/functions/runtimes/dart/index.ts | 28 +++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 7b0b9abe449..49b85f85ad4 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -97,15 +97,24 @@ export class Delegate implements runtimes.RuntimeDelegate { buildRunnerProcess.on("error", reject); }); - // Compile Dart to native Linux executable + // Cross-compile Dart to a Linux x86_64 executable for Cloud Run. + // Requires Dart 3.8+ for --target-os and --target-arch support. const binDir = path.join(this.sourceDir, "bin"); await fs.promises.mkdir(binDir, { recursive: true }); - logLabeledBullet("functions", "compiling Dart to native executable..."); + logLabeledBullet("functions", "compiling Dart to linux-x64 executable..."); const compileProcess = spawn( this.bin, - ["compile", "exe", "lib/main.dart", "-o", "bin/server", "--target-os=linux"], + [ + "compile", + "exe", + "lib/main.dart", + "-o", + "bin/server", + "--target-os=linux", + "--target-arch=x64", + ], { cwd: this.sourceDir, stdio: ["ignore", "pipe", "pipe"], @@ -127,7 +136,8 @@ export class Delegate implements runtimes.RuntimeDelegate { reject( new FirebaseError( `Dart compilation failed with exit code ${code}. ` + - `Make sure your Dart project compiles successfully with: dart compile exe lib/main.dart`, + `Make sure your Dart project compiles successfully with: ` + + `dart compile exe lib/main.dart --target-os=linux --target-arch=x64`, ), ); } @@ -265,9 +275,13 @@ export class Delegate implements runtimes.RuntimeDelegate { } // Normalize "run" → "gcfv2" for emulator compatibility. - for (const ep of Object.values(discovered.endpoints)) { - if (ep.platform === "run") { - ep.platform = "gcfv2"; + // The emulator doesn't support "run" platform, but production deploys need it. + const isEmulator = !!process.env["FIREBASE_EMULATOR_HUB"]; + if (isEmulator) { + for (const ep of Object.values(discovered.endpoints)) { + if (ep.platform === "run") { + ep.platform = "gcfv2"; + } } } From 80a29b757b678fc44ed0a54b11b24bc39037fd01 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 25 Feb 2026 15:27:20 +0100 Subject: [PATCH 16/21] emulator fix --- src/emulator/functionsEmulator.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index 7afc4d7dc25..e84b979f6a3 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -424,7 +424,7 @@ export class FunctionsEmulator implements EmulatorInstance { // For Dart, include the function name in the path so the server can route // For other runtimes, use / as they use FUNCTION_TARGET env var const isDart = isLanguageRuntime(record.backend.runtime, "dart"); - const path = isDart ? `/${trigger.name}` : `/`; + const path = isDart ? `/${trigger.entryPoint}` : `/`; return new Promise((resolve, reject) => { const req = http.request( @@ -1874,10 +1874,12 @@ export class FunctionsEmulator implements EmulatorInstance { // For Dart, route via path since all functions share a single process. // The Dart server routes based on the first path segment (function name). - // Use trigger_name (e.g. "helloWorld") not trigger.id (e.g. "us-central1-helloWorld"). + // Use trigger.entryPoint (e.g. "helloworld") which is the actual function name + // registered in the Dart server, not trigger_name which may include region prefix + // (e.g. "us-central1-helloworld-0" from background function routes). const isDart = isLanguageRuntime(record.backend.runtime, "dart"); if (isDart) { - path = `/${req.params.trigger_name}${path === "/" ? "" : path}`; + path = `/${trigger.entryPoint}${path === "/" ? "" : path}`; } // We do this instead of just 302'ing because many HTTP clients don't respect 302s so it may From 2e6c13eff5670f3f46fda56b2ffbe6ef754aaa9e Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Wed, 25 Feb 2026 15:54:21 +0100 Subject: [PATCH 17/21] fix rooting --- src/emulator/functionsEmulator.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index e84b979f6a3..d6cdad8c3e6 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -1879,7 +1879,16 @@ export class FunctionsEmulator implements EmulatorInstance { // (e.g. "us-central1-helloworld-0" from background function routes). const isDart = isLanguageRuntime(record.backend.runtime, "dart"); if (isDart) { - path = `/${trigger.entryPoint}${path === "/" ? "" : path}`; + // Background trigger routes (e.g., /functions/projects/.../triggers/...) + // leave a path artifact (/functions/projects/) after regex replacement. + // Only append remaining path for HTTP trigger routes where the user may + // have sub-paths (e.g., /helloworld/extra/path). + const isBackgroundRoute = req.url.startsWith("/functions/projects/"); + if (isBackgroundRoute || path === "/") { + path = `/${trigger.entryPoint}`; + } else { + path = `/${trigger.entryPoint}${path}`; + } } // We do this instead of just 302'ing because many HTTP clients don't respect 302s so it may From 54a0dc108224e8864a77822e9f04053e481a61dc Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 26 Feb 2026 10:38:29 +0100 Subject: [PATCH 18/21] fix emulator detection --- src/deploy/functions/runtimes/dart/index.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 49b85f85ad4..189e502e670 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -230,7 +230,7 @@ export class Delegate implements runtimes.RuntimeDelegate { async discoverBuild( _configValues: backend.RuntimeConfigValues, // eslint-disable-line @typescript-eslint/no-unused-vars - _envs: backend.EnvironmentVariables, // eslint-disable-line @typescript-eslint/no-unused-vars + envs: backend.EnvironmentVariables, ): Promise { const yamlDir = this.sourceDir; const yamlPath = path.join(yamlDir, "functions.yaml"); @@ -274,13 +274,15 @@ export class Delegate implements runtimes.RuntimeDelegate { } } - // Normalize "run" → "gcfv2" for emulator compatibility. - // The emulator doesn't support "run" platform, but production deploys need it. - const isEmulator = !!process.env["FIREBASE_EMULATOR_HUB"]; - if (isEmulator) { + // The Dart manifest emits platform "gcfv2" so the emulator treats + // functions as v2 CloudEvent endpoints (getSignatureType needs "gcfv2"). + // During deploy, convert to "run" so fabricator.ts creates Cloud Run services. + // The emulator passes FUNCTIONS_EMULATOR=true in envs; deploy does not. + const isEmulator = envs.FUNCTIONS_EMULATOR === "true"; + if (!isEmulator) { for (const ep of Object.values(discovered.endpoints)) { - if (ep.platform === "run") { - ep.platform = "gcfv2"; + if (ep.platform === "gcfv2") { + (ep as any).platform = "run"; } } } From 8f837d059b967c767a977b9bcad84250f74fbc66 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 26 Feb 2026 10:52:08 +0100 Subject: [PATCH 19/21] skipping compilation in emulator run --- src/deploy/functions/runtimes/dart/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 189e502e670..e110401066d 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -12,6 +12,8 @@ import { logger } from "../../../../logger"; import { FirebaseError } from "../../../../error"; import { logLabeledBullet } from "../../../../utils"; import { Build } from "../../build"; +import { EmulatorRegistry } from "../../../../emulator/registry"; +import { Emulators } from "../../../../emulator/types"; /** * Create a runtime delegate for the Dart runtime, if applicable. @@ -98,6 +100,13 @@ export class Delegate implements runtimes.RuntimeDelegate { }); // Cross-compile Dart to a Linux x86_64 executable for Cloud Run. + // Skip compilation when running in the emulator (the emulator runs + // Dart source directly via `dart run`). + if (EmulatorRegistry.isRunning(Emulators.FUNCTIONS)) { + logger.debug("Skipping Dart compilation in emulator mode."); + return; + } + // Requires Dart 3.8+ for --target-os and --target-arch support. const binDir = path.join(this.sourceDir, "bin"); await fs.promises.mkdir(binDir, { recursive: true }); From f01ac10ffbd5f72cd956915b4b2100d87d361bed Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 26 Feb 2026 11:19:41 +0100 Subject: [PATCH 20/21] clean output --- src/deploy/functions/runtimes/dart/index.ts | 23 +++++++--- src/deploy/functions/runtimes/index.ts | 2 +- src/emulator/functionsEmulator.ts | 50 ++++++++++++--------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index e110401066d..7d3d4d96309 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -64,6 +64,12 @@ export class Delegate implements runtimes.RuntimeDelegate { } async build(): Promise { + // If build_runner watch is already running, it handles rebuilds automatically. + // Skip running build_runner build to avoid an infinite reload loop. + if (this.buildRunnerProcess) { + return; + } + // Run build_runner to generate up-to-date functions.yaml logLabeledBullet("functions", "running build_runner..."); @@ -162,7 +168,7 @@ export class Delegate implements runtimes.RuntimeDelegate { * Returns a cleanup function that stops the build_runner process. * The returned promise resolves once the initial build completes. */ - async watch(): Promise<() => Promise> { + async watch(onRebuild?: () => void): Promise<() => Promise> { logger.debug("Starting build_runner watch for Dart functions..."); const buildRunnerProcess = spawn( @@ -184,14 +190,21 @@ export class Delegate implements runtimes.RuntimeDelegate { rejectInitialBuild = reject; }); + const buildCompletePattern = /Succeeded after|Built with build_runner/; + buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => { const output = chunk.toString("utf8").trim(); if (output) { logger.debug(`[build_runner] ${output}`); - if (!initialBuildComplete && output.includes("Succeeded after")) { - initialBuildComplete = true; - logger.debug("build_runner initial build completed"); - resolveInitialBuild(); + if (buildCompletePattern.test(output)) { + if (!initialBuildComplete) { + initialBuildComplete = true; + logger.debug("build_runner initial build completed"); + resolveInitialBuild(); + } else if (onRebuild) { + // Subsequent rebuild detected — notify the emulator to reload triggers + onRebuild(); + } } } }); diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index cdb13b32bb4..7c055403eb7 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -47,7 +47,7 @@ export interface RuntimeDelegate { * This is for languages like TypeScript which have a "watch" feature. * Returns a cancel function. */ - watch(): Promise<() => Promise>; + watch(onRebuild?: () => void): Promise<() => Promise>; /** * Inspect the customer's source for the backend spec it describes. diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index d6cdad8c3e6..ad4fb09d13e 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -486,9 +486,11 @@ export class FunctionsEmulator implements EmulatorInstance { const isDart = isLanguageRuntime(backend.runtime, "dart"); - // For Dart, start build_runner watch via the delegate's watch() method. - // This waits for the initial build to complete before continuing. if (isDart) { + // For Dart, build_runner watch handles source file watching and rebuilds + // functions.yaml automatically. We use its onRebuild callback to reload + // triggers, avoiding chokidar entirely (which would cause infinite loops + // since loadTriggers runs build_runner build which rewrites functions.yaml). const runtimeDelegateContext: runtimes.DelegateContext = { projectId: this.args.projectId, projectDir: this.args.projectDir, @@ -501,29 +503,33 @@ export class FunctionsEmulator implements EmulatorInstance { "functions", `Starting build_runner watch for Dart functions...`, ); - const cleanup = await delegate.watch(); + const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); + const cleanup = await delegate.watch(() => { + this.logger.log("DEBUG", "build_runner rebuilt, reloading triggers"); + debouncedLoadTriggers(); + }); this.watchCleanups.push(cleanup); this.logger.logLabeled("SUCCESS", "functions", `build_runner initial build completed`); + } else { + const watcher = chokidar.watch(backend.functionsDir, { + ignored: [ + /(^|[\/\\])\../, // Ignore hidden files/dirs (covers .dart_tool, .git, etc.) + /.+\.log/, // Ignore log files + /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules + /.+?[\\\/]venv[\\\/].+?/, // Ignore venv + ...(backend.ignore?.map((i) => `**/${i}`) ?? []), + ], + persistent: true, + }); + + this.watchers.push(watcher); + + const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); + watcher.on("change", (filePath) => { + this.logger.log("DEBUG", `File ${filePath} changed, reloading triggers`); + return debouncedLoadTriggers(); + }); } - - const watcher = chokidar.watch(backend.functionsDir, { - ignored: [ - /(^|[\/\\])\../, // Ignore hidden files/dirs (covers .dart_tool, .git, etc.) - /.+\.log/, // Ignore log files - /.+?[\\\/]node_modules[\\\/].+?/, // Ignore node_modules - /.+?[\\\/]venv[\\\/].+?/, // Ignore venv - ...(backend.ignore?.map((i) => `**/${i}`) ?? []), - ], - persistent: true, - }); - - this.watchers.push(watcher); - - const debouncedLoadTriggers = debounce(() => this.loadTriggers(backend), 1000); - watcher.on("change", (filePath) => { - this.logger.log("DEBUG", `File ${filePath} changed, reloading triggers`); - return debouncedLoadTriggers(); - }); } await this.performPostLoadOperations(); return; From d9ed582e058475e953824c267d3ffe694040321a Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Thu, 26 Feb 2026 11:27:12 +0100 Subject: [PATCH 21/21] prevent duplicate watches --- src/deploy/functions/runtimes/dart/index.ts | 8 +++++--- src/emulator/functionsEmulator.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts index 7d3d4d96309..a53580ff49e 100644 --- a/src/deploy/functions/runtimes/dart/index.ts +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -46,6 +46,7 @@ export class Delegate implements runtimes.RuntimeDelegate { public readonly language = "dart"; public readonly bin = "dart"; + private static watchModeActive = false; private buildRunnerProcess: ChildProcess | null = null; constructor( @@ -64,9 +65,9 @@ export class Delegate implements runtimes.RuntimeDelegate { } async build(): Promise { - // If build_runner watch is already running, it handles rebuilds automatically. - // Skip running build_runner build to avoid an infinite reload loop. - if (this.buildRunnerProcess) { + // If build_runner watch is already running (on any delegate instance), + // it handles rebuilds automatically. Skip to avoid infinite reload loops. + if (Delegate.watchModeActive) { return; } @@ -169,6 +170,7 @@ export class Delegate implements runtimes.RuntimeDelegate { * The returned promise resolves once the initial build completes. */ async watch(onRebuild?: () => void): Promise<() => Promise> { + Delegate.watchModeActive = true; logger.debug("Starting build_runner watch for Dart functions..."); const buildRunnerProcess = spawn( diff --git a/src/emulator/functionsEmulator.ts b/src/emulator/functionsEmulator.ts index ad4fb09d13e..29b2681e283 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -1724,7 +1724,7 @@ export class FunctionsEmulator implements EmulatorInstance { port: 8081 + randomInt(0, 1000), // Add a small jitter to avoid race condition. }); - const args = ["--enable-vm-service", "lib/main.dart"]; + const args = ["run", "--no-serve-devtools", "lib/main.dart"]; // For Dart, don't set FUNCTION_TARGET in environment - the server loads all functions // and routes based on the request path (similar to Python's functions-framework)