diff --git a/scripts/agent-evals/templates/crashlytics-flutter/.gitignore b/scripts/agent-evals/templates/crashlytics-flutter/.gitignore index 9decf67ec35..acfcfa1242f 100644 --- a/scripts/agent-evals/templates/crashlytics-flutter/.gitignore +++ b/scripts/agent-evals/templates/crashlytics-flutter/.gitignore @@ -55,5 +55,11 @@ captures/ # Formatter definitions .idea/codeStyles/ +# Dart/Flutter generated files +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock + # Misc .DS_Store diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index 5dde3219789..2958c567562 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -249,7 +249,6 @@ export type Endpoint = Triggered & { platform?: "gcfv1" | "gcfv2" | "run"; // 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; // The services account that this function should run as. 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; - }, - }; -} diff --git a/src/deploy/functions/runtimes/dart/index.ts b/src/deploy/functions/runtimes/dart/index.ts new file mode 100644 index 00000000000..a26979fd5da --- /dev/null +++ b/src/deploy/functions/runtimes/dart/index.ts @@ -0,0 +1,398 @@ +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"; +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"; +import { EmulatorRegistry } from "../../../../emulator/registry"; +import { Emulators } from "../../../../emulator/types"; + +/** + * 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)); +} + +/** + * Minimum Dart SDK version required. + * Dart 3.8+ is needed for cross-compilation flags (--target-os, --target-arch). + */ +const MIN_DART_SDK_VERSION = "3.8.0"; + +/** Default entry point for Dart functions projects. */ +export const DART_ENTRY_POINT = "bin/server.dart"; + +export class Delegate implements runtimes.RuntimeDelegate { + public readonly language = "dart"; + public readonly bin = "dart"; + public readonly entryPoint = DART_ENTRY_POINT; + + private static watchModeActive = false; + private buildRunnerProcess: ChildProcess | null = null; + + constructor( + private readonly projectId: string, + private readonly sourceDir: string, + public readonly runtime: supported.Runtime & supported.RuntimeOf<"dart">, + ) {} + + async validate(): Promise { + // Verify the Dart binary exists and meets the minimum version requirement. + const result = spawn.sync(this.bin, ["--version"], { + encoding: "utf8", + timeout: 10_000, + }); + + if (result.error) { + throw new FirebaseError( + `Could not find a Dart SDK. Make sure the '${this.bin}' command is available on your PATH.`, + ); + } + + // `dart --version` outputs e.g. "Dart SDK version: 3.8.0 (stable) ... on "macos_arm64"" + const versionOutput = (result.stdout || result.stderr || "").toString(); + const match = /Dart SDK version:\s*(\d+\.\d+\.\d+)/.exec(versionOutput); + if (match) { + const installedVersion = match[1]; + if (installedVersion.localeCompare(MIN_DART_SDK_VERSION, undefined, { numeric: true }) < 0) { + throw new FirebaseError( + `Dart SDK version ${installedVersion} is not supported. ` + + `Firebase Functions for Dart requires Dart ${MIN_DART_SDK_VERSION} or later. ` + + `Please upgrade your Dart SDK.`, + ); + } + } else { + logger.debug(`Could not parse Dart SDK version from: ${versionOutput}`); + } + + // Verify pubspec.yaml exists and is readable. + const pubspecYamlPath = path.join(this.sourceDir, "pubspec.yaml"); + try { + await fs.promises.access(pubspecYamlPath, fs.constants.R_OK); + } catch (err: any) { + throw new FirebaseError(`Failed to read pubspec.yaml at ${pubspecYamlPath}: ${err.message}`); + } + + // Verify the entry point file exists. + const entryPointPath = path.join(this.sourceDir, this.entryPoint); + try { + await fs.promises.access(entryPointPath, fs.constants.R_OK); + } catch (err: any) { + throw new FirebaseError( + `Could not find entry point at ${entryPointPath}. ` + + `Firebase Functions for Dart expects your main function in ${this.entryPoint}.`, + ); + } + + // Run `dart pub get` if dependencies have not been resolved yet. + const packageConfigPath = path.join(this.sourceDir, ".dart_tool", "package_config.json"); + try { + await fs.promises.access(packageConfigPath, fs.constants.R_OK); + } catch { + logLabeledBullet("functions", "running dart pub get..."); + const pubGetProcess = spawn(this.bin, ["pub", "get"], { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }); + pubGetProcess.stdout?.on("data", (chunk: Buffer) => { + logger.debug(`[dart pub get] ${chunk.toString("utf8").trim()}`); + }); + pubGetProcess.stderr?.on("data", (chunk: Buffer) => { + logger.debug(`[dart pub get] ${chunk.toString("utf8").trim()}`); + }); + await new Promise((resolve, reject) => { + pubGetProcess.on("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + } else { + reject( + new FirebaseError( + `dart pub get failed with exit code ${code}. ` + + `Make sure your pubspec.yaml is valid and dependencies are available.`, + ), + ); + } + }); + pubGetProcess.on("error", reject); + }); + } + } + + async build(): Promise { + // 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; + } + + // 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); + }); + + // 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; + } + + const binDir = path.join(this.sourceDir, "bin"); + await fs.promises.mkdir(binDir, { recursive: true }); + + logLabeledBullet("functions", "compiling Dart to linux-x64 executable..."); + + const compileProcess = spawn( + this.bin, + [ + "compile", + "exe", + this.entryPoint, + "-o", + "bin/server", + "--target-os=linux", + "--target-arch=x64", + ], + { + cwd: this.sourceDir, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + compileProcess.stdout?.on("data", (chunk: Buffer) => { + logger.debug(`[dart compile] ${chunk.toString("utf8").trim()}`); + }); + 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 ${this.entryPoint} --target-os=linux --target-arch=x64`, + ), + ); + } + }); + compileProcess.on("error", reject); + }); + + logLabeledBullet("functions", "Dart compilation complete."); + } + + /** + * 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(onRebuild?: () => void): Promise<() => Promise> { + Delegate.watchModeActive = true; + 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; + }); + + 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 (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(); + } + } + } + }); + + 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}. Initial build failed.`); + if (!initialBuildComplete) { + 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}. Your Dart functions may not be deployed or emulated correctly.`, + ); + 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, + ): Promise { + const yamlDir = this.sourceDir; + const yamlPath = path.join(yamlDir, "functions.yaml"); + let discovered = await discovery.detectFromYaml(yamlDir, this.projectId, this.runtime); + + if (!discovered) { + 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"], + }); + + 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); + }); + + 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.`, + ); + } + } + + // 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 === "gcfv2") { + (ep as any).platform = "run"; + } + } + } + + return discovered; + } +} diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index 9ad33973183..7c055403eb7 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -1,11 +1,11 @@ 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"; import { FirebaseError } from "../../../error"; import * as supported from "./supported"; -import * as dart from "./dart"; import * as experiments from "../../../experiments"; /** @@ -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/deploy/functions/runtimes/supported/index.ts b/src/deploy/functions/runtimes/supported/index.ts index 683949a23c0..21bef65beba 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 be5d5288f27..29271006ccd 100644 --- a/src/emulator/functionsEmulator.ts +++ b/src/emulator/functionsEmulator.ts @@ -60,7 +60,8 @@ 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 { DART_ENTRY_POINT } from "../deploy/functions/runtimes/dart"; import { ExtensionsEmulator } from "./extensionsEmulator"; const EVENT_INVOKE_GA4 = "functions_invoke"; // event name GA4 (alphanumertic) @@ -222,6 +223,7 @@ export class FunctionsEmulator implements EmulatorInstance { private staticBackends: EmulatableBackend[] = []; private dynamicBackends: EmulatableBackend[] = []; private watchers: chokidar.FSWatcher[] = []; + private watchCleanups: Array<() => Promise> = []; debugMode = false; @@ -399,7 +401,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]; - if (!pool.readyForWork(trigger.id)) { + if (!pool.readyForWork(trigger.id, record.backend.runtime)) { try { await this.startRuntime(record.backend, trigger); } catch (e: any) { @@ -407,7 +409,7 @@ export class FunctionsEmulator implements EmulatorInstance { return; } } - const worker = pool.getIdleWorker(trigger.id)!; + const worker = pool.getIdleWorker(trigger.id, record.backend.runtime)!; if (this.debugMode) { await worker.sendDebugMsg({ functionTarget: trigger.entryPoint, @@ -419,11 +421,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 = isLanguageRuntime(record.backend.runtime, "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, @@ -473,26 +482,55 @@ 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}`) ?? []), - ], - 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(); - }); - + // First load triggers to discover the runtime type await this.loadTriggers(backend, /* force= */ true); + + const isDart = isLanguageRuntime(backend.runtime, "dart"); + + 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, + 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 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(); + }); + } } await this.performPostLoadOperations(); return; @@ -519,6 +557,12 @@ export class FunctionsEmulator implements EmulatorInstance { } this.watchers = []; + // Stop delegate watch processes (e.g., build_runner for Dart) + for (const cleanup of this.watchCleanups) { + await cleanup(); + } + this.watchCleanups = []; + if (this.destroyServer) { await this.destroyServer(); } @@ -1668,6 +1712,57 @@ 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 = ["run", "--no-serve-devtools", DART_ENTRY_POINT]; + + // 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, @@ -1676,8 +1771,10 @@ 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 (isLanguageRuntime(backend.runtime, "dart")) { + runtime = await this.startDart(backend, { ...runtimeEnv, ...secretEnvs }); } else { runtime = await this.startNode(backend, { ...runtimeEnv, ...secretEnvs }); } @@ -1687,7 +1784,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; } @@ -1777,18 +1874,37 @@ 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, route via path since all functions share a single process. + // The Dart server routes based on the first path segment (function name). + // 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) { + // 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 // 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)) { + if (!pool.readyForWork(trigger.id, record.backend.runtime)) { try { await this.startRuntime(record.backend, trigger); } catch (e: any) { @@ -1817,6 +1933,7 @@ export class FunctionsEmulator implements EmulatorInstance { res as http.ServerResponse, reqBody, debugBundle, + record.backend.runtime, ); } } diff --git a/src/emulator/functionsRuntimeWorker.ts b/src/emulator/functionsRuntimeWorker.ts index 5c32326f296..0593dc5a2a0 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; @@ -296,12 +297,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 worker process. + // Dart loads all functions into a single process and routes based on request path. + if (isLanguageRuntime(runtime, "dart")) { + return "~dart-shared~"; + } + return triggerId || "~diagnostic~"; } /** @@ -345,8 +350,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 +371,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 +386,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 +412,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 +427,24 @@ 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..65c8d00643a --- /dev/null +++ b/src/init/features/functions/dart.ts @@ -0,0 +1,38 @@ +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"; + +const PUBSPEC_TEMPLATE = readTemplateSync("init/functions/dart/pubspec.yaml"); +const MAIN_TEMPLATE = readTemplateSync("init/functions/dart/server.dart"); +const GITIGNORE_TEMPLATE = readTemplateSync("init/functions/dart/_gitignore"); + +/** + * 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}/bin/server.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 4641045387e..af2370f2ee9 100644 --- a/src/init/features/functions/index.ts +++ b/src/init/features/functions/index.ts @@ -177,6 +177,10 @@ async function languageSetup(setup: any): Promise { value: "python", }); } + choices.push({ + name: "Dart", + value: "dart", + }); const language = await select({ message: "What language would you like to use to write Cloud Functions?", default: "javascript", @@ -208,6 +212,18 @@ async function languageSetup(setup: any): 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; } 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/pubspec.yaml b/templates/init/functions/dart/pubspec.yaml new file mode 100644 index 00000000000..a4ea9bddbeb --- /dev/null +++ b/templates/init/functions/dart/pubspec.yaml @@ -0,0 +1,14 @@ +name: functions_template +description: An app using Firebase Functions for Dart +version: 1.0.0 + +environment: + sdk: ^3.8.0 + +dependencies: + # TODO(ehesp): Replace with published package version once available on pub.dev + firebase_functions: + path: ../ + +dev_dependencies: + build_runner: ^2.4.0 diff --git a/templates/init/functions/dart/server.dart b/templates/init/functions/dart/server.dart new file mode 100644 index 00000000000..23abeaf2de0 --- /dev/null +++ b/templates/init/functions/dart/server.dart @@ -0,0 +1,15 @@ +import 'package:firebase_functions/firebase_functions.dart'; + +void main(List args) { + fireUp(args, (firebase) { + // Set maxInstances to control costs during unexpected traffic spikes. + firebase.https.onRequest( + name: 'helloWorld', + options: const HttpsOptions( + cors: Cors(['*']), + maxInstances: Instances(10), + ), + (request) async => Response(200, body: 'Hello from Dart Functions!'), + ); + }); +}