@@ -224,6 +224,7 @@ export class FunctionsEmulator implements EmulatorInstance {
224224 private staticBackends : EmulatableBackend [ ] = [ ] ;
225225 private dynamicBackends : EmulatableBackend [ ] = [ ] ;
226226 private watchers : chokidar . FSWatcher [ ] = [ ] ;
227+ private buildRunnerProcesses : Map < string , ChildProcess > = new Map ( ) ;
227228
228229 debugMode = false ;
229230
@@ -475,6 +476,61 @@ export class FunctionsEmulator implements EmulatorInstance {
475476 return Promise . resolve ( ) ;
476477 }
477478
479+ /**
480+ * Starts build_runner in watch mode for a Dart backend.
481+ * This watches Dart source files and regenerates functions.yaml when they change.
482+ */
483+ private startBuildRunnerWatch ( backend : EmulatableBackend ) : void {
484+ const bin = backend . bin || "dart" ;
485+ const codebase = backend . codebase ;
486+
487+ this . logger . logLabeled (
488+ "BULLET" ,
489+ "functions" ,
490+ `Starting build_runner watch for Dart functions...` ,
491+ ) ;
492+
493+ const buildRunnerProcess = spawn ( bin , [ "run" , "build_runner" , "watch" , "--delete-conflicting-outputs" ] , {
494+ cwd : backend . functionsDir ,
495+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
496+ } ) ;
497+
498+ buildRunnerProcess . stdout ?. on ( "data" , ( chunk : Buffer ) => {
499+ const output = chunk . toString ( "utf8" ) . trim ( ) ;
500+ if ( output ) {
501+ this . logger . log ( "DEBUG" , `[build_runner] ${ output } ` ) ;
502+ }
503+ } ) ;
504+
505+ buildRunnerProcess . stderr ?. on ( "data" , ( chunk : Buffer ) => {
506+ const output = chunk . toString ( "utf8" ) . trim ( ) ;
507+ if ( output ) {
508+ this . logger . log ( "DEBUG" , `[build_runner] ${ output } ` ) ;
509+ }
510+ } ) ;
511+
512+ buildRunnerProcess . on ( "exit" , ( code ) => {
513+ if ( code !== 0 && code !== null ) {
514+ this . logger . logLabeled (
515+ "WARN" ,
516+ "functions" ,
517+ `build_runner exited with code ${ code } . Hot reload may not work.` ,
518+ ) ;
519+ }
520+ this . buildRunnerProcesses . delete ( codebase ) ;
521+ } ) ;
522+
523+ buildRunnerProcess . on ( "error" , ( err ) => {
524+ this . logger . logLabeled (
525+ "WARN" ,
526+ "functions" ,
527+ `Failed to start build_runner: ${ err . message } ` ,
528+ ) ;
529+ } ) ;
530+
531+ this . buildRunnerProcesses . set ( codebase , buildRunnerProcess ) ;
532+ }
533+
478534 async connect ( ) : Promise < void > {
479535 for ( const backend of this . staticBackends ) {
480536 this . logger . logLabeled (
@@ -483,15 +539,26 @@ export class FunctionsEmulator implements EmulatorInstance {
483539 `Watching "${ backend . functionsDir } " for Cloud Functions...` ,
484540 ) ;
485541
486- // For Dart runtimes, watch only the YAML spec file since Dart handles its own hot reload
542+ // First load triggers to discover the runtime type
543+ await this . loadTriggers ( backend , /* force= */ true ) ;
544+
545+ // Now we can check if it's Dart (runtime is set by loadTriggers -> discoverTriggers)
487546 const isDart = backend . runtime ?. startsWith ( "dart" ) ;
488- const watchPath = isDart
489- ? path . join ( backend . functionsDir , ".dart_tool" , "firebase" , "functions.yaml" )
490- : backend . functionsDir ;
547+ this . logger . log ( "DEBUG" , `Runtime: ${ backend . runtime } , isDart: ${ isDart } ` ) ;
491548
492- const watcher = chokidar . watch ( watchPath , {
549+ // For Dart runtimes, start build_runner watch to regenerate functions.yaml on source changes
550+ if ( isDart ) {
551+ this . startBuildRunnerWatch ( backend ) ;
552+ }
553+ const watcher = chokidar . watch ( backend . functionsDir , {
493554 ignored : isDart
494- ? [ ] // For Dart, we're watching a specific file, so no ignore patterns needed
555+ ? [
556+ / .+ ?[ \\ \/ ] \. d a r t _ t o o l [ \\ \/ ] .+ ?/ , // Ignore .dart_tool (build outputs)
557+ / .+ ?[ \\ \/ ] \. p a c k a g e s / , // Ignore .packages
558+ / .+ ?[ \\ \/ ] b u i l d [ \\ \/ ] .+ ?/ , // Ignore build directory
559+ / ( ^ | [ \/ \\ ] ) \. ./ , // Ignore hidden files
560+ / .+ \. l o g / , // Ignore log files
561+ ]
495562 : [
496563 / .+ ?[ \\ \/ ] n o d e _ m o d u l e s [ \\ \/ ] .+ ?/ , // Ignore node_modules
497564 / ( ^ | [ \/ \\ ] ) \. ./ , // Ignore files which begin the a period
@@ -504,13 +571,45 @@ export class FunctionsEmulator implements EmulatorInstance {
504571
505572 this . watchers . push ( watcher ) ;
506573
507- const debouncedLoadTriggers = debounce ( ( ) => this . loadTriggers ( backend ) , 1000 ) ;
508- watcher . on ( "change" , ( filePath ) => {
509- this . logger . log ( "DEBUG" , `File ${ filePath } changed, reloading triggers` ) ;
510- return debouncedLoadTriggers ( ) ;
574+ // Log when watcher is ready
575+ watcher . on ( "ready" , ( ) => {
576+ this . logger . log ( "DEBUG" , `File watcher ready for ${ backend . functionsDir } ` ) ;
511577 } ) ;
512578
513- await this . loadTriggers ( backend , /* force= */ true ) ;
579+ if ( isDart ) {
580+ // For Dart, reload triggers and refresh workers when source files change
581+ const debouncedReload = debounce ( async ( ) => {
582+ this . logger . logLabeled ( "BULLET" , "functions" , "Source file changed, reloading..." ) ;
583+ // Re-discover triggers in case function signatures changed (build_runner updates functions.yaml)
584+ await this . loadTriggers ( backend ) ;
585+ } , 1000 ) ;
586+ watcher . on ( "change" , ( filePath ) => {
587+ this . logger . log ( "DEBUG" , `Detected change: ${ filePath } ` ) ;
588+ return debouncedReload ( ) ;
589+ } ) ;
590+
591+ // Also watch functions.yaml specifically - when build_runner regenerates it,
592+ // we need to reload to discover new/changed function signatures
593+ const functionsYamlPath = path . join ( backend . functionsDir , ".dart_tool" , "firebase" , "functions.yaml" ) ;
594+ const yamlWatcher = chokidar . watch ( functionsYamlPath , { persistent : true } ) ;
595+ this . watchers . push ( yamlWatcher ) ;
596+
597+ const debouncedYamlReload = debounce ( async ( ) => {
598+ this . logger . logLabeled ( "BULLET" , "functions" , "Function definitions changed, reloading..." ) ;
599+ await this . loadTriggers ( backend ) ;
600+ } , 500 ) ;
601+ yamlWatcher . on ( "change" , ( ) => {
602+ this . logger . log ( "DEBUG" , "functions.yaml changed" ) ;
603+ return debouncedYamlReload ( ) ;
604+ } ) ;
605+ } else {
606+ // For Node.js/Python, re-discover triggers on change
607+ const debouncedLoadTriggers = debounce ( ( ) => this . loadTriggers ( backend ) , 1000 ) ;
608+ watcher . on ( "change" , ( filePath ) => {
609+ this . logger . log ( "DEBUG" , `File ${ filePath } changed, reloading triggers` ) ;
610+ return debouncedLoadTriggers ( ) ;
611+ } ) ;
612+ }
514613 }
515614 await this . performPostLoadOperations ( ) ;
516615 return ;
@@ -537,6 +636,15 @@ export class FunctionsEmulator implements EmulatorInstance {
537636 }
538637 this . watchers = [ ] ;
539638
639+ // Stop all build_runner processes for Dart backends
640+ for ( const [ codebase , proc ] of this . buildRunnerProcesses ) {
641+ this . logger . log ( "DEBUG" , `Stopping build_runner for ${ codebase } ` ) ;
642+ if ( ! proc . killed && proc . exitCode === null ) {
643+ proc . kill ( "SIGTERM" ) ;
644+ }
645+ }
646+ this . buildRunnerProcesses . clear ( ) ;
647+
540648 if ( this . destroyServer ) {
541649 await this . destroyServer ( ) ;
542650 }
0 commit comments