All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
- Docker image: runtime dependencies no longer resolve to
ERR_MODULE_NOT_FOUND. The runtime stage copies only the rootnode_modulesnext todist, but pnpm's default isolated linker symlinked thewhistlerspackage's own dependencies underpackages/ts/whistlers/node_modules(never copied into the image). The bundled server (dist/bin/server.js) therefore crash-looped at the first lazy import —@nats-io/transport-nodefor any queue (DESTINATION_TYPE=sseandfirebase) andfirebase-adminforDESTINATION_TYPE=firebase— so the official image could not run as a NATS/MQTT bridge at all. Added a root.npmrcwithnode-linker=hoisted— and copy it into the Dockerfile build stage beforepnpm install— so the build produces a flat rootnode_modulescontaining every runtime dependency (queue adapters + the optionalfirebase-adminpeer), which the runtime stage's existingCOPYthen captures. No source or API change.
- Declarative FCM message templating (
SubscriptionConfig.fcm) — a subscription may now carry anfcm: { messages: FcmMessageTemplate[] }config that the bundled server (bin/server.ts,DESTINATION_TYPE=firebase) compiles into theFirebaseDestinationformat. Templates express what previously required a customformatfunction in code: nested payload fields ({ field: "params.productId" }),android/apnsoptions, FCM self-exclusionconditions ({ condition: { template, vars } }interpolating{topic}+ regex-guarded payload vars), and multi-message arrays (e.g. a visible placeholder + a data-only upgrade). Template nodes —FieldRef,Coalesce,ConditionTemplate— and therenderFcmMessagescompiler are exported. This lets the officialdrakkarsoftware/whistlersimage run product-specific push shaping entirely from a config file, with no custom build. Omittingfcmis fully backward-compatible (notification+dataFieldsforwarding unchanged). OutgoingNotification.subscription— theWhistlerbridge now attaches the matchedSubscriptionConfigto each notification so config-driven destinations (such as the FCM templating above) can apply per-subscription formatting.
- The bundled server passes a shared FCM
formatto everyFirebaseDestination(per-namespace routes and the default app) that renderssubscription.fcmwhen present and otherwise forwardsnotification/dataexactly as before.
- Multi-message sends in
FirebaseDestination— theformatcallback may now return an array of message bodies; each element is sent as its own FCM message (viamessaging.sendEach, one batch round trip) and is addressed independently — the existingcondition-vs-topicrule is applied per element. This enables fanning out several messages for a single event, e.g. anotificationplaceholder the OS shows even when the app can't run plus adata-only message that wakes a background handler to replace it, both carrying the same exclusioncondition. A single object (the common case) is unchanged — exactly onemessaging.send— and an empty array sends nothing. FCM does not guarantee delivery ordering between the messages in a batch. FirebaseDestinationOptions.multiSendFailure("resolve" | "throw", default"resolve") — controls how a multi-message batch with some (not all) failures is handled:"resolve"swallows partial failures so a delivered message isn't undone by a sibling's failure;"throw"rejects if any message fails. A batch where every message fails always rejects, regardless of the setting. Single-message sends are unaffected (they reject on failure as before).
FirebaseDestination.sendnormalizes the formatter output to a list and dispatchesmessaging.sendfor one message ormessaging.sendEachfor several; the per-messagetopic/conditionresolution is unchanged.
- FCM condition addressing in
FirebaseDestination— theformatcallback may now return a non-emptycondition(an FCM condition expression, boolean over up to 5 topics, e.g."'A' in topics && !('B' in topics)"). When present, the message is sent withconditionand withouttopic(FCM accepts one or the other, never both); an absent or empty-stringconditionfalls back to the normal topic send, so existing formatters are unaffected. This enables targeting a combination of topics — e.g. delivering to a topic's subscribers while excluding those also subscribed to another topic.topicstill cannot be set byformat(it is stripped, as before).
FirebaseDestination.sendstrips bothtopicandconditionfrom the formatted body before re-applying exactly one:conditionwhen non-empty, otherwise the subscription'stopic.
NamespaceRoutingDestination— a destination adapter that dispatches each notification to a per-namespaceDestinationAdapterbased onOutgoingNotification.namespace. Options:routes(aRecord<string, DestinationAdapter>keyed by namespace) and an optionaldefaultused for root (non-namespaced) notifications and unknown namespaces. With no matching route and nodefault,send()throws (surfaced throughonError) rather than dropping the message.close()closes every wrapped adapter once (deduplicated by identity), awaiting all even if some reject and rethrowing the first failure. Destination-agnostic — routes can be any adapter. Exported alongsideNamespaceRoutingDestinationOptions.- Primary use case: one Firebase project per namespace. Initialize one firebase-admin named app per namespace (each with its own service-account key) and pass a
FirebaseDestination({ app })per route.
- Primary use case: one Firebase project per namespace. Initialize one firebase-admin named app per namespace (each with its own service-account key) and pass a
- Per-namespace Firebase from JSON config —
NamespaceConfignow accepts an optionalfirebaseCredentials?: string(a path to a service-account JSON key file). The bundled server (bin/server.ts,DESTINATION_TYPE=firebase) initializes a dedicated firebase-admin app per namespace that has it and wraps everything in aNamespaceRoutingDestination; root subscriptions and namespaces without the field use Application Default Credentials. The default (ADC) app is initialized only when something falls through to it — when every namespace has its ownfirebaseCredentialsand there are no root subscriptions, the server skips it, so ADC is not required. Validated as a non-empty string. Only a path is accepted (never inline credentials); read only by the bundled server, ignored by theWhistlerbridge and other destination types. - Example
examples/ts/nats-namespaces-multi-firebase.ts— routing each namespace to its own Firebase project viaNamespaceRoutingDestination.
- Namespace-based config —
WhistlersConfignow accepts an optionalnamespacesrecord (namespaces?: Record<string, NamespaceConfig>). Each namespace is a named group ofSubscriptionConfigentries that:- Prefixes their destination topics with
{namespace}-at runtime (applied even whendestinationTopicis set explicitly, and to the source-derived default). - Attaches the namespace name as
namespaceon everyOutgoingNotification, letting destination adapters segment traffic by namespace. - Scopes subscription-name uniqueness (the same name may appear in different namespaces or in root without conflict).
- Prefixes their destination topics with
parseConfigJson(raw: string): WhistlersConfig— parses and validates a JSON config string, throwing a descriptive error for bad JSON or an invalid config. Exported from the package; used internally bybin/server.ts(replaces the inlineJSON.parse+assertValidConfigthat was there before).NamespaceConfigtype exported from the package.CreateConfigOptions.namespaces?— pass namespaces directly tocreateConfig.OutgoingNotification.namespace?: string— the namespace of the matched subscription, when present.WhistlerOptions.onErrorcontext now includesnamespace?: string.- Example
examples/ts/nats-namespaces-to-firebase.ts— demonstrates per-tenant namespacing with amakeTenantNamespacefactory.
- NATS adapter migrated off the deprecated
natsv2 package to@nats-io/transport-nodev3 (the nats.js v3 package split).NatsQueueAdapter's public API is unchanged — sameserversoption andnats://URLs — so no consumer code changes are required. Message decoding now uses the built-inMsg.string()method (theStringCodec/JSONCodechelpers were removed in v3). - Bumped
mqtt(^5.10.1→^5.15.1). - Bumped build/test tooling:
typescript5.x→6.x,vitest2.x→4.x(now requiresvite, pinned to8.x),@types/node20.x→22.x(aligned with the Node 22 runtime),firebase-admindev dependency13.4→13.10. Refreshed@aws-sdk/client-s3,@clickhouse/client,pg, and@types/pgto their latest in-range versions.peerDependenciesranges are unchanged, so consumers are not forced to upgrade.
SSEDestination— runs an HTTP server (built-innode:http, no extra dependency) and streams each notification to connected Server-Sent Events clients. Start it withlisten(port, host?)(returns the boundAddressInfo; use0for an ephemeral port) beforewhistler.start(), or inject an existinghttp.Servervia theserveroption (its lifecycle stays the caller's —close()detaches the handler and ends client streams but never closes an injected server). Clients filter per-connection with the?topic=query parameter (repeatable; omitted = all topics). A configurable heartbeat (heartbeatMs, default15000,0disables) keeps idle connections alive. Theformatcallback returns astring(used as thedata:payload as-is), aRecord<string, unknown>(JSON-serialised intodata:), or anSSEEventInitfor full control ofdata/event/id/retry; defaults areevent= topic,id= random UUID,data= the JSON notification.bin/server.tsstandalone server re-introduced. Reads a JSON config file (path from first CLI argument, default/etc/whistlers/config.json), initialises a NATS or MQTT queue adapter (controlled byQUEUE_TYPE/QUEUE_URLenv vars), and starts aFirebaseDestinationbridge. HandlesSIGINT/SIGTERMfor graceful shutdown.bin/server.tsnow selects the destination via theDESTINATION_TYPEenv var (firebasedefault, orsseusingSSE_PORT/SSE_PATH).firebase-adminis imported lazily, so thessepath runs without the optionalfirebase-adminpeer dependency installed.
- Ansible role
defaults/main.yml: Node.js version updated from 20 to 22 to match the Docker image and CI. - Ansible role
defaults/main.yml: repo URL updated toDrakkar-Software/Whistlers. - Ansible role
whistlers.service.j2: systemd unit description and documentation URL updated to reflect the current project location and generic destination support. README.md: Ansible requirements example URL updated toDrakkar-Software/Whistlers.
formatcallback option on all destination adapters — override the default content sent to each destination:FirebaseDestination: returns FCM message fields (notification,data,android,apns, etc.) merged with the mandatorytopic(which cannot be overridden).ClickHouseDestination: returns aRecord<string, unknown>inserted as a JSONEachRow row.PostgresDestination: returns aRecord<string, unknown>; keys become double-quoted column names in a dynamicINSERT.S3Destination: returns astring(ContentType: text/plain, no.jsonkey extension) or aRecord<string, unknown>(JSON-serialised,ContentType: application/json,.jsonkey extension).
- Breaking:
parseConfigJsonremoved. UsecreateConfigto build config from code. bin/server.tsstandalone server removed (it depended on JSON config).
ClickHouseDestination— inserts each notification as a row into a ClickHouse table (@clickhouse/clientpeer dependency).PostgresDestination— inserts each notification as a row into a PostgreSQL table (pgpeer dependency). Queries use parameterized placeholders.S3Destination— writes each notification as a JSON object to S3. Keys follow{prefix}{topic}/{uuid}.json. Accepts a pre-configuredS3Clientfor custom endpoints. (@aws-sdk/client-s3peer dependency).- All three new destinations are optional peer dependencies; install only what you use.
- README and code comments no longer refer to Firebase / FCM specifically — Whistlers is now presented as a generic queue-to-destination bridge.
OutgoingNotification.topicdoc comment updated from "FCM-safe" to "destination topic name".SubscriptionConfig.destinationTopic/notification/dataFieldscomments made destination-agnostic.- Log message in
Whistlerupdated: "→ FCM topic" → "→".
- Consumer groups now work end-to-end. The
groupfield inSubscriptionConfigwas documented and validated but never reached the queue adapters. The bridge now collects(topic, group)pairs and passes them through:NatsQueueAdapter: callsnc.subscribe(topic, { queue: group })when a group is set.MqttQueueAdapter: applies the$share/{group}/topicshared-subscription prefix when a group is set.
Whistler.stop()andstart()now share the same subscription-collection logic via a privatecollectSubscriptions()helper, eliminating the risk of them going out of sync.
TopicSubscriptioninterface ({ topic: string; group?: string }) exported from the package.QueueAdapter.subscribeandunsubscribenow acceptTopicSubscription[]instead ofstring[].infra/ansible/roles/whistlers— Ansible role to deploy Whistlers on Debian/Ubuntu. Installs Node.js, pnpm, clones the repo, builds, and manages a systemd service.CustomQueueAdaptercallbacks (onSubscribe,onUnsubscribe) now receiveTopicSubscription[]instead ofstring[], carrying group information to test code.
- Initial release.
QueueAdapterinterface withNatsQueueAdapter,MqttQueueAdapter, andMemoryQueueAdapterimplementations.DestinationAdapterinterface withFirebaseDestinationandMemoryDestinationimplementations.Whistlerbridge class: connects queue to destination, topic-pattern matching,dataFieldsextraction,onErrorcallback, graceful start/stop.- JSON config (
parseConfigJson) and code config (createConfig) with full validation. sanitizeTopicutility exported for custom topic name transformations.CustomQueueAdapterfor pluggable test delivery logic.