Skip to content

Commit 8f90eef

Browse files
committed
feat: add support for hot reloading
1 parent f107067 commit 8f90eef

2 files changed

Lines changed: 121 additions & 78 deletions

File tree

src/deploy/functions/runtimes/dart/index.ts

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as fs from "fs";
22
import * as path from "path";
33
import { promisify } from "util";
4-
import { ChildProcess } from "child_process";
54
import * as spawn from "cross-spawn";
65

76
import * as runtimes from "..";
@@ -75,72 +74,8 @@ export class Delegate implements runtimes.RuntimeDelegate {
7574
}
7675

7776
watch(): Promise<() => Promise<void>> {
78-
const dartRunProcess = spawn(this.bin, ["run", this.sourceDir], {
79-
cwd: this.sourceDir,
80-
stdio: ["ignore", "pipe", "pipe"],
81-
});
82-
83-
const buildRunnerProcess = spawn(this.bin, ["run", "build_runner", "watch", "-d"], {
84-
cwd: this.sourceDir,
85-
stdio: ["ignore", "pipe", "pipe"],
86-
});
87-
88-
// Log output from both processes
89-
dartRunProcess.stdout?.on("data", (chunk: Buffer) => {
90-
logger.info(`[dart run] ${chunk.toString("utf8")}`);
91-
});
92-
dartRunProcess.stderr?.on("data", (chunk: Buffer) => {
93-
logger.error(`[dart run] ${chunk.toString("utf8")}`);
94-
});
95-
96-
buildRunnerProcess.stdout?.on("data", (chunk: Buffer) => {
97-
logger.info(`[build_runner] ${chunk.toString("utf8")}`);
98-
});
99-
buildRunnerProcess.stderr?.on("data", (chunk: Buffer) => {
100-
logger.error(`[build_runner] ${chunk.toString("utf8")}`);
101-
});
102-
103-
// Return cleanup function
104-
return Promise.resolve(async () => {
105-
const killProcess = (proc: ChildProcess) => {
106-
if (!proc.killed && proc.exitCode === null) {
107-
proc.kill("SIGTERM");
108-
}
109-
};
110-
111-
// Try graceful shutdown first
112-
killProcess(dartRunProcess);
113-
killProcess(buildRunnerProcess);
114-
115-
// Wait a bit for graceful shutdown
116-
await new Promise((resolve) => setTimeout(resolve, 2000));
117-
118-
// Force kill if still running
119-
if (!dartRunProcess.killed && dartRunProcess.exitCode === null) {
120-
dartRunProcess.kill("SIGKILL");
121-
}
122-
if (!buildRunnerProcess.killed && buildRunnerProcess.exitCode === null) {
123-
buildRunnerProcess.kill("SIGKILL");
124-
}
125-
126-
// Wait for both processes to exit
127-
await Promise.all([
128-
new Promise<void>((resolve) => {
129-
if (dartRunProcess.killed || dartRunProcess.exitCode !== null) {
130-
resolve();
131-
} else {
132-
dartRunProcess.once("exit", () => resolve());
133-
}
134-
}),
135-
new Promise<void>((resolve) => {
136-
if (buildRunnerProcess.killed || buildRunnerProcess.exitCode !== null) {
137-
resolve();
138-
} else {
139-
buildRunnerProcess.once("exit", () => resolve());
140-
}
141-
}),
142-
]);
143-
});
77+
// No-op: The FunctionsEmulator handles build_runner watch for hot reload
78+
return Promise.resolve(() => Promise.resolve());
14479
}
14580

14681
async discoverBuild(

src/emulator/functionsEmulator.ts

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
/.+?[\\\/]\.dart_tool[\\\/].+?/, // Ignore .dart_tool (build outputs)
557+
/.+?[\\\/]\.packages/, // Ignore .packages
558+
/.+?[\\\/]build[\\\/].+?/, // Ignore build directory
559+
/(^|[\/\\])\../, // Ignore hidden files
560+
/.+\.log/, // Ignore log files
561+
]
495562
: [
496563
/.+?[\\\/]node_modules[\\\/].+?/, // 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

Comments
 (0)