hidane is a reimplementation of the Cloud Firestore emulator as a single Rust binary: no JDK, the same gRPC, REST and WebChannel wire protocols, and it runs under firebase-tools with hidane exec -- firebase emulators:start. Its rule is that every claim of compatibility has a test behind it: scripts record how the official emulator answers, and hidane's test suite replays every recording.
Until now, one thing was missing under emulators:start: Firestore-triggered Cloud Functions. Since 0.2.3 hidane accepted the Functions emulator's trigger registrations, so functions loaded, but no event ever reached them. 0.4.0 delivers those events. This post is about how.
How the two emulators talk
When you run firebase emulators:start with Firestore and Functions, firebase-tools starts the Firestore emulator with --functions_emulator <host:port>. The Functions emulator then registers each Firestore trigger with the Firestore emulator. After a commit, the Firestore emulator sends an event for each document the commit changed to every trigger that watches it, as POST /functions/projects/{p}/triggers/{key} on the Functions emulator.
None of this is a documented API. It is a conversation between two local processes, and the only reliable description of it is what the official emulator actually sends.
Recording it from the outside
hidane treats the official emulator as a black box: its jar is never decompiled, and everything below was observed on the wire (docs/clean-room describes the policy).
tools/oracle/function_events.py starts the official emulator (v1.22.0, the version firebase-tools pins) three times, under an English locale:
- with
--functions_emulatorpointing at a small server of the script's own, which records every request it receives and answers200, or500,404or a late answer depending on the trigger's key; - without the flag;
- with the flag pointing at a port nothing listens on.
Against each, it registers triggers the way firebase-tools does, writes over REST, gRPC Commit and the Write stream, and records for every step: the answer, every request the Functions emulator received (in order, every header, the body as sent), and the WARNING lines the emulator printed.
What it sends
v1 triggers (providers/cloud.firestore/eventTypes/document.*) get a compact JSON body:
{"data":{"value":…,"oldValue":…,"updateMask":{}},
"context":{"eventId":…,"timestamp":…,"eventType":…,
"resource":{"name":…,"service":"firestore.googleapis.com"}}}
A few details only a recording shows: the v1 triggers of one change share the same eventId; updateMask is always empty; the timestamp is the commit time; and v1 triggers only ever see the (default) database.
v2 triggers (google.cloud.firestore.document.v1.*, including withAuthContext) get a binary-mode CloudEvent. The attributes travel as ce-* headers (ce-type, ce-source, ce-id, ce-subject, ce-time, ce-database, ce-document, and so on), and the body is a google.events.cloud.firestore.v1.DocumentEventData protobuf, base64-encoded, which firebase-tools decodes. For withAuthContext, the emulator sends ce-authtype: unknown and the same placeholder ce-authid whoever wrote.
The order: a Java HashMap
Ordering looked simple at first: all v1 events of a commit, then the v2 ones, document by document in the order the writes name them. But when several triggers watch the same document, they did not come in registration order, nor alphabetically.
They come in the iteration order of a Java HashMap sized for the project's triggers of that version. The model was fitted on 116 observations, and hidane reproduces it in presized_hash_order:
pub fn presized_hash_order<S: AsRef<str>>(keys: &[S]) -> Vec<usize> {
// `(int) Math.ceil(n / 0.75)`, then `tableSizeFor`.
let wanted = (keys.len() * 4).div_ceil(3).max(1);
let capacity = u32::try_from(wanted.next_power_of_two()).unwrap_or(1 << 30);
let mut ordered: Vec<(usize, u32)> = keys
.iter()
.enumerate()
.map(|(i, key)| (i, spread(string_hash(key.as_ref())) & (capacity - 1)))
.collect();
ordered.sort_by_key(|&(i, bucket)| (bucket, i));
ordered.into_iter().map(|(i, _)| i).collect()
}
string_hash is Java's String.hashCode (base 31 over UTF-16 code units), spread folds the high half into the low one, and the table is the smallest power of two that holds n / 0.75 entries. Keys come out by bucket, and by insertion within a bucket. Functions that depend on the order in which their siblings fire are fragile, but a test suite that passes on the official emulator should not start failing because the order changed.
Timing and failures
Events go out after the commit, from one background queue, so a write never waits for a function. Each event waits for the answer to the previous one, for up to 20 seconds. hidane does the same, one event at a time from a task of its own.
When delivery fails (an error status, a refused connection, a timeout), the official emulator logs a WARNING and does not retry, for example Unsuccessful HTTP response when publishing event for … or Failed to publish event to {host}: Connection refused. Without --functions_emulator, every event is a WARNING that the Functions emulator host/port is not configured. hidane prints the same wording.
Batches, transactions, :batchWrite, gRPC Commit, the Write stream, deleted paths and cleared databases send events; imports and POST /reset do not.
Replaying it
crates/hidane/tests/function_events.rs replays the 133 steps of the three runs against hidane and compares the status of each answer, every request the Functions emulator receives (v1 bodies byte for byte, v2 bodies decoded, with times and IDs compared as labels), and every WARNING.
Recording also turned up something unrelated: Java prints the smallest double as 4.9E-324, and hidane printed 5.0E-324, over REST and WebChannel too. That is fixed now.
What hidane does not copy
Matching the official emulator is the goal, but not its bugs. Each of these is listed in docs/parity-exceptions.md:
- A v2 event's
ce-source(a function'sevent.source) names the project twice://firestore.googleapis.com/projects/projects/{p}/databases/{d}. hidane names it once, as production does. - A write that changes nothing sends v2
updatedandwrittenevents, and deleting a missing document sends v2createdandwritten; v1 triggers get neither. hidane sends no event for either, as production does. -
POST /resetmakes the official emulator forget every registered trigger, so functions stop firing, silently, until they reload. hidane keeps them. - A v2 trigger registered without a database makes every write to the project answer
500. hidane has it watch(default).
End to end
Recordings check the wire; the last check is whether functions actually receive the same data. tools/oracle/functions/ is a functions project (firebase-functions 6.6.0) with v1 onCreate / onUpdate / onDelete / onWrite, v2 onDocumentCreated / Updated / Deleted / Written, onDocumentWrittenWithAuthContext, a subcollection and a named database. It runs under firebase emulators:exec --only functions,firestore (firebase-tools 15.33.0), once with the official emulator and once under hidane exec.
The same 37 events reach the same functions with the same data. The official run has 6 more, the v2 events for the no-op write and the missing delete, and projects/projects/ in event.source; both transcripts are in results/functions-triggers-*.json.
Also in 0.4.0
- A commit's problems are reported in the official emulator's order.
- The Go, Python and Java server SDKs are verified against both emulators: documents, queries, aggregations, vector search, transactions and listeners.
- A weekly CI job starts the official emulator, re-records a set of recordings and fails if one differs from the committed fixture.
- The parity table now counts 20,224 recorded answers that the test suite replays.
Try it
curl -fsSL https://hidane.dev/install.sh | sh # or Homebrew, npm, cargo, pub, Docker
hidane exec -- firebase emulators:start
Other ways to install:
brew install hidane-dev/tap/hidane
npm install --save-dev hidane
cargo binstall hidane
docker run --rm -p 8080:8080 ghcr.io/hidane-dev/hidane
- Website: https://hidane.dev
- GitHub: https://github.com/hidane-dev/hidane
- Release notes: https://github.com/hidane-dev/hidane/releases/tag/v0.4.0
hidane is also listed on DevHunt this week: https://devhunt.org/tool/hidane
If your tests get a different answer from hidane than from the official emulator, please open an issue with the Parity gap template. That is the most useful feedback.
Unofficial; not affiliated with Google. Firebase and Cloud Firestore are trademarks of Google LLC.
Top comments (0)