Running the cloud-broker emulators locally
The GCP Pub/Sub and Azure Service Bus backends ship guarded end-to-end suites that run against the vendors’ own local emulators. Neither needs a cloud account, a credential, or a billing profile.
Both suites are ignored when their environment variable is absent, so an ordinary deno task test
is unaffected — and the ignored count is what proves they ran when it is set. They are not wired
into CI — see “Why not CI” below.
Their endpoints ARE in packages/messaging-plugin/deno.json’s scoped test.permissions.net
allowlist, so both run under deno task test and not only under a standalone --allow-all. That
grant is endpoint-scoped rather than loopback-wide, for the reason M53 recorded: a wide grant lets a
retrying client (ioredis) spin against ECONNREFUSED until the runner is killed. Adding an emulator
on a new port therefore means adding that port here and to the allowlist.
GCP Pub/Sub
Google ships the emulator inside the gcloud CLI image. The @google-cloud/pubsub SDK honours
PUBSUB_EMULATOR_HOST natively and skips authentication entirely when it is set.
docker run -d --name he-pubsub -p 8085:8085 \
gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators \
gcloud beta emulators pubsub start --project=he-test --host-port=0.0.0.0:8085
PUBSUB_EMULATOR_HOST=127.0.0.1:8085 PUBSUB_PROJECT_ID=he-test \
deno test --allow-all packages/messaging-plugin/test/e2e/pubsub-emulator.test.ts
docker rm -f he-pubsub
Address the emulator by IP, not by localhost. Under the package’s own scoped net grant
(deno task test, which passes -P), grpc-js resolves a hostname through DNS, and a host:port
permission does not authorize that lookup — the suite fails with
14 UNAVAILABLE: Name resolution failed for target dns:localhost:8085. An IP literal skips
resolution entirely, so 127.0.0.1:8085 needs no permission beyond the endpoint itself. This is
invisible with --allow-all, which is why the standalone command above works either way.
Restart the emulator between runs. The suite suffixes its own topics per run, but the RPC case
uses the shared messaging.replies reply topic and a second consecutive run against the same
emulator instance fails with RequestTimeoutError: Request timed out waiting for a reply.
docker restart he-pubsub (its state is in memory) before each run.
What it proves that a fake cannot: delivery through the real gRPC streaming pull into the
on('message') bridge; that a handler throw reaches the platform as a nack and produces a genuine
redelivery; and that the RPC reply inbox’s topic.createSubscription / subscription.delete() pair
behaves as the design assumes — asserted by listing subscriptions on the reply topic while the
broker is up and again after stop().
Azure Service Bus
Microsoft’s emulator is config-driven and needs a SQL Edge sidecar. Entities come from a mounted
Config.json; the emulator creates nothing at runtime.
The emulator’s own AMQP port is 5672, which is also RabbitMQ’s — and CI’s RabbitMQ service, or a
local container serving RABBITMQ_URL for the M70c outage suites, already holds it. Publish the
emulator on 5673 and name that port in the endpoint; UseDevelopmentEmulator=true accepts one.
docker network create he-sbnet
docker run -d --name he-sqledge --network he-sbnet \
-e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='<strong-password>' \
mcr.microsoft.com/azure-sql-edge:latest
# Wait for readiness rather than guessing: the emulator fails to start against a
# SQL Edge that is not yet accepting connections.
until docker logs he-sqledge 2>&1 | grep -q 'Recovery is complete'; do sleep 2; done
docker run -d --name he-sb --network he-sbnet -p 5673:5672 \
-v "$PWD/docs/fixtures/servicebus-emulator-config.json:/ServiceBus_Emulator/ConfigFiles/Config.json" \
-e ACCEPT_EULA=Y -e SQL_SERVER=he-sqledge -e MSSQL_SA_PASSWORD='<strong-password>' \
mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
until docker logs he-sb 2>&1 | grep -q 'Application started'; do sleep 2; done
SERVICEBUS_CONNECTION_STRING='Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;' \
deno test --allow-all packages/messaging-plugin/test/e2e/service-bus-emulator.test.ts
docker rm -f he-sb he-sqledge && docker network rm he-sbnet
What it proves that a fake cannot: that createReceiver(topicName, subscriptionName) with
autoCompleteMessages: false really hands settlement to the receiver, that completeMessage and
abandonMessage reach the service (an abandon produces a real redelivery), and that the AMQP
receiver-link teardown works against a live connection.
Two emulator behaviours shaped the suite, and both are properties of the broker rather than defects. A Service Bus subscription accrues every message published to its topic whether or not a receiver is attached, so each case takes its own topic — sharing one lets an earlier case’s message arrive in a later one. And competing receivers on a single subscription share messages between them, so each case stops its app before the next starts.
The emulator supports no management operations.
ServiceBusAdministrationClient.createSubscription fails against it with a RestError, so RPC
cannot be round-tripped there — the reply inbox needs a per-instance subscription. That limitation
is itself useful: it is the one place the inbox’s failure path can be driven against a real broker,
and the suite asserts it surfaces ReplyInboxUnavailableError naming the reply topic and the
Manage right. Service Bus RPC remains unverified against real Azure.
The same limitation reaches the health indicator, and it is worth knowing before you read one.
ServiceBusBroker’s reachability probe is an administration call, and the emulator’s administration
endpoint has no TLS listener — the SDK throws RestError/ECONNRESET after about six seconds. So
against the emulator the probe can never succeed, and the indicator reports
{ "status": "up", "data": { "broker": "service-bus", "reachable": "unknown" } }
unknown rather than down: a probe that never reached the namespace has learned nothing about it,
so it must not drain a replica whose data plane is publishing fine. Before this was corrected (V5-2)
the same configuration answered down and /ready returned 503 while publish returned 200. A
namespace that positively answers unhealthy — a 404 for one that has been deleted, say — is still
reported down, because that is an answer rather than a silence.
Against the emulator specifically, that means the indicator cannot tell a running broker from a
stopped one. Measured: with the container stopped it also reports up/unknown and /ready
stays 200, because there is no administration endpoint to answer either way — only the publish
itself fails. This is a property of the emulator, not of a real namespace, where a Manage-capable
credential succeeds and a send/listen-only one is refused with a 401 that counts as reachable. Do
not use the emulator to exercise health transitions; use it for the data plane, which is what it
implements. A publish against a stopped broker also holds the request for the SDK’s full retry
budget — shorten it with retryOptions (X28-6) if a test needs to fail fast.
AWS SQS
packages/queue-plugin/test/e2e/sqs-elasticmq.test.ts runs against ElasticMQ and is wired into
CI. See .github/workflows/ci.yml.
docker run -d --name he-elasticmq -p 9324:9324 softwaremill/elasticmq-native:1.7.1
SQS_ENDPOINT_URL=http://localhost:9324 deno task test
docker rm -f he-elasticmq
Why not CI
ElasticMQ is one container with no credentials and no licence, so it earns its place in the
workflow. The other two do not, for different reasons: the Pub/Sub emulator image carries the whole
gcloud SDK, and the Service Bus emulator needs a EULA acceptance plus a SQL Edge sidecar and a
mounted config file — two multi-container additions for backends whose logic is a thin translation
over a pure, fully unit-tested adapter. Running them locally before a release is the intended
workflow.
A scaffolded workspace on an emulator
setu new acme --workspace --transport pubsub (or --transport service-bus) wires every member to
the transport and emits a Compose stack that starts the emulator beside them, so this needs no
docker run of its own:
docker compose -f docker/compose.yaml up -d # emulator + every member
Neither arm needs a credential. Each reads its connection value from the environment
(PUBSUB_PROJECT_ID, SERVICE_BUS_CONNECTION_STRING) and falls back to the vendor’s documented
local-emulator setting, which is what makes an unconfigured workspace work.
Two operational facts a developer cannot guess, both carried in the generated README:
- Pub/Sub does not create topics.
publishposts to an existing topic andsubscribecreates only the subscription, so create each topic first —curl -X PUT $PUBSUB_EMULATOR_HOST/v1/projects/$PUBSUB_PROJECT_ID/topics/<name>. - The Service Bus emulator creates no entities at all. Every topic must be declared in the
generated
docker/servicebus-config.jsonwith amessaging-consumerssubscription — the broker’s own default consumer group — before the container starts.
Both paths were verified end to end: two generated members, one publishing and one receiving, over
each real emulator. A stale subscription is worth knowing about on the Pub/Sub side: one bound to a
deleted topic silently swallows delivery, because subscribe treats ALREADY_EXISTS as success and
then opens a subscription attached to nothing. Reset the emulator between unrelated runs.