Cross-service events
When the producer and the subscribers of an integration event are
separate deploys — orders publishes order.placed, a separately deployed billing service reacts — you
publish once, from heximon.config.ts's crossService.transport, and every subscribed service gets its own copy.
The producer's publish() call is identical to the single-deploy case; only the transport underneath it changes,
and that's a deploy-time choice, not an app-code change.
Choosing a leg
| Leg | Delivery | Runs on | Use when | Example |
|---|---|---|---|---|
| Per-service-queue split (default) | N enqueues, one per subscriber | Any queue transport | No native broker, or you want the simplest mental model | fanout |
| Redis Streams broadcast | 1 XADD, N consumer groups | Node/Bun/Deno subscriber | Already on Redis/Upstash and want producer-side savings | broadcast |
| SNS → SQS broadcast | 1 Publish, N SQS queues | Node/Bun/Deno subscriber | AWS-native deploy | sns |
| Cloudflare DO router | 1 publish, router fans out | Cloudflare Workers | No native CF broker; want producer-blindness | notify-split |
| QStash push | 1 HTTP POST, N pushed deliveries | Any platform, incl. freezing functions | Subscriber is Vercel / Netlify / Lambda | qstash |
Every service joins the fabric the same way — service: "<id>" names its own deploy identity, and crossService
opts it in. On vp build / vp dev, the host reads crossService, discovers who subscribes to what by scanning
the workspace (by default, walking up to the nearest pnpm-workspace.yaml), and threads that topology into the
compile — the producer's build bakes the distributor, the subscriber's build bakes the consumer, with no boot
seam in either service:
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: {},
});
Renaming a service's service: "<id>" orphans its queue, so change it deliberately.
Scanning a nested services directory
The default scan only reaches as far as the nearest pnpm-workspace.yaml — fine when your services already sit
at the workspace root. When they're nested a level deeper — siblings under their own services/ directory inside
a larger monorepo — point the scan there explicitly with workspaceRoot. Heximon's own gap examples lay every
cross-service leg out exactly this way (examples/gap/cross-service-transports/<leg>/services/<name>/heximon.config.ts),
so each service's config overrides the scan to start one level up, at its services/ parent:
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: { workspaceRoot: ".." },
});
workspaceRoot is relative to the service's own root (or absolute); ".." here means "scan my parent directory's
children" — every sibling under services/ — instead of walking up further and picking up every unrelated app in
the outer monorepo.
Per-service-queue split (the default)
With no native broker to fan a single write to every subscriber, the producer splits: at build time it learns
which services subscribe to each event, and fans one copy per subscriber into that subscriber's own durable
queue, heximon-ie-<service>. Over a SQL transport this is a shared-store story — both deploys point at one
database, and the producer's distributor writes into the same queue_messages table the subscriber's consumer
drains. A platform with a native per-service queue (Cloudflare Queues) routes the split through that instead.
No transport is declared for this leg — it's the fallback when crossService opts in with no emitter:
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: {},
});
The gap's fanout example
is the runnable split baseline: an orders producer, a billing subscriber, and an end-to-end test that boots
both services and drives POST /orders through the shared queue_messages table to OnOrderPlaced.handle.
Native broadcast — Redis Streams
A broker with native pub/sub does not need to split: the producer writes once, and every subscriber reads its
own copy. On Redis Streams that's one XADD to a single shared stream plus one consumer group per subscribing
service (heximon-ie-<service>) — Redis fans the one entry to every group. Select it with a distributor-emitter
instance on crossService.transport and bind the @upstash/redis client to the RedisStreamClient token:
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { RedisStreamsFanout } from "@heximon/queue/upstash/compiler";
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: { transport: new RedisStreamsFanout() },
});
It sits behind the same IntegrationEventTransport port, so only the producer write (1 XADD instead of N
enqueues) and the read (a consumer group per service) change — the outbox/relay path is untouched. Because several
producers can share one stream, each event carries its producing-service source, which the subscriber folds
into its idempotency key (idempotency:queue:<subscriber>:<source>:<id>) so two producers minting the same id
never collide.
A native broker is also immune to late-subscriber starvation: with the split leg, a producer fans only to subscribers it knew about at build time, but the stream retains entries, so a subscriber that joins later reads the backlog it missed. The Redis Streams consumer is a long-lived poll loop, so this leg runs on a Node/Bun/Deno deploy, not a freezing function — and bounding the shared stream needs Redis 8.2+ (or Upstash) for its ACK-aware trim, which only reclaims entries every consumer group has acknowledged.
The gap's broadcast example
is the runnable flagship for this leg: an orders producer + two subscribers (billing, analytics), with an
end-to-end test that proves the defining property — one XADD reaches every subscriber's consumer group.
Native broadcast — AWS SNS to SQS
The AWS counterpart of Redis Streams: select crossService.transport: new SnsFanout() and bind an SnsClient
(producer) + SqsClient (each subscriber). The producer does one Publish to a shared SNS topic; SNS fans the
message to one SQS queue per subscribing service (heximon-ie-<service>), each drained by the reused
SqsQueueConsumer.
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { SnsFanout } from "@heximon/queue/sns/compiler";
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: { transport: new SnsFanout() },
});
The topic, the per-service queues, and the topic-to-queue subscriptions are deploy-provisioned (Terraform /
CloudFormation), and each subscription must enable raw message delivery — without it, SNS wraps the body in a
JSON envelope before depositing it into SQS, and the SqsQueueConsumer, which decodes the raw published body,
would fail.
The topic ARN and each service's own queue URL are read from the environment at runtime — see the
@heximon/queue/sns README
for the full env-variable contract. The gap's SNS example
ships the Terraform infra/ and a DEPLOY.md for the live AWS round-trip.
Cloudflare escape hatch — the DO router
Cloudflare has no native broadcast product (Pub/Sub was retired), so on Workers the router is the hub: a
single Durable Object holds a runtime subscription registry and fans out itself. Select it with
crossService.transport: new CloudflareDoRouterFanout():
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { DurablePlugin } from "@heximon/durable/compiler";
import { CloudflareDoRouterFanout } from "@heximon/cloudflare/compiler";
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin(), new DurablePlugin()],
service: "orders",
crossService: { transport: new CloudflareDoRouterFanout() },
});
The producer does ONE publish to the router and never names a subscriber. Each subscriber self-registers its
delivery queue with the router at runtime, so the registry — not the producer's build — is the authoritative
subscriber set; the router fans every event onto each subscriber's own queue, drained by the reused
CloudflareQueueConsumer. That buys producer-blindness: add a subscriber in a new repo without rebuilding the
producer. It costs for it: the single router DO is a per-topic SPOF with a ~1000 req/s ceiling, and the fan-out is
(2N+1) queue ops plus DO compute — more than the split's ~3N. So the router is a documented opt-in escape
hatch, and the per-service-queue split stays the Cloudflare default.
The router host lists the shipped DO under durable; a subscriber lists none — it reaches the router
cross-worker through a script_name Durable Object namespace binding. The flagship notify-split app
is the runnable DO-router example — the flagship's real @ticketing/notifications context redeployed as its own
Cloudflare Worker, fed only by the cross-service event, with a DEPLOY.md multi-worker recipe (provision queues →
set the host's Queues REST credentials → vp build each service → deploy host-first → verify).
Push broadcast for freezing platforms — Upstash QStash
Every leg above needs a subscriber that can run a long-lived poll loop (SQL / Redis / SQS) or that is a Cloudflare
Worker. A Vercel / Netlify / AWS Lambda function freezes between invocations and can host neither — the only
delivery it accepts is an HTTP request the platform wakes it for. So the producer publishes to an external push
broker that POSTs each event to the subscriber's own route. Select crossService.transport: new QStashFanout()
and bind a QStashClient:
import { defineHeximonConfig } from "@heximon/build";
import { IntegrationEventsPlugin } from "@heximon/integration/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { QStashFanout } from "@heximon/queue/upstash/compiler";
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: { transport: new QStashFanout() },
});
The producer does one HTTP POST per event to a QStash URL Group, which pushes the message to every
endpoint registered under it (a subscriber's HTTPS route) — broadcast, producer-blind. The subscriber side is the
one leg with no generated consumer: the app writes its own controller and delegates to the injectable
QStashReceiver, which verifies the Upstash-Signature (self-contained WebCrypto HMAC, no SDK) and dispatches
the decoded envelope into the same queue:channel tier:
import type { Controller, Post } from "@heximon/http";
import { QStashReceiver } from "@heximon/queue/upstash";
export class IntegrationEventsController implements Controller<"/integration-events"> {
public constructor(private readonly receiver: QStashReceiver) {}
public async receive(action: Post<"/">): Promise<{ ok: boolean }> {
const rawBody = await action.request.text();
const signature = action.request.headers.get("Upstash-Signature") ?? "";
if (!(await this.receiver.verify(rawBody, signature))) {
action.response.status = 401;
return { ok: false };
}
await this.receiver.dispatch(rawBody);
return { ok: true };
}
}
It sits behind the same IntegrationEventTransport port, and because the publish runs over plain fetch from any
runtime, the build accepts a QStash producer on a freezing platform too — the fan-out gate is transport-aware. The
URL Group, its endpoints, and the signing keys are deploy-provisioned (the QStash REST API or dashboard); the
producer reads HEXIMON_QSTASH_URL_GROUP + QSTASH_TOKEN, each subscriber reads QSTASH_CURRENT_SIGNING_KEY /
QSTASH_NEXT_SIGNING_KEY. The gap's QStash example
is the runnable push leg — an in-process QStashSimulator proves one publish reaches every subscriber, plus a
forged-signature 401.
Verification status
Every leg above is proven in-process (createTestApp booting the real services side by side over a shared
fake/simulator) and against real vp build output. A live multi-service deploy is verified for one leg only —
everything else is the documented, expected-but-unverified next step:
- Cloudflare DO router — no live multi-worker
wrangler deployyet; the flagshipnotify-splitapp'sDEPLOY.mdis the recipe, and the router's own registry + fan-out logic is unit-tested against the realIntegrationEventRouterRoomDurable Object. - Redis Streams broadcast — verified with an in-process fake + real
vp buildartifacts; no live multi-service run yet. - SNS → SQS broadcast — verified with an in-process fake + real
vp buildartifacts; the live AWS round-trip is thesnsexample'sDEPLOY.md(Terraform-provisioned topic + queues). - QStash push broadcast — verified with the in-process
QStashSimulator+ realvp buildartifacts; the live Vercel/Netlify round-trip is theqstashexample'sDEPLOY.md.
See docs/live-deploy-verification.md
for the dated ledger behind every "verified" claim in these docs.
See also
- Integration Events — the typed
IntegrationEventproducer, theIntegrationEventHandlerconsumer, and the transactional outbox these fan-out legs build on. - Queue — the durable Drizzle/SQS/Upstash transports and the polling-consumer model the split and broadcast legs are built from.
- Cloudflare — Durable Objects,
wrangler.jsongeneration, and the platform the DO router runs on.
Hosts
Where a Heximon app's fetch server lives — the heximon() Vite dev server, standalone Node/Bun/Deno, Nitro, Nuxt, unplugin for other bundlers, library mode, and the mixed-runtime split.
The heximon CLI
heximon create, deploy, provision, and migrations — the commands the unscoped heximon package ships, scaffolding new apps, resolving credentials, running vp build, and shelling the vendor CLI for you.