Heximon Logo
Deploy

Cross-service events

Fan an integration event out to independently deployed services — the per-service-queue split, Redis Streams and SNS-to-SQS native broadcast, the Cloudflare DO-router escape hatch, and QStash push broadcast for freezing platforms — selected via crossService.transport.

When the producer and the subscribers of an integration event are separate deploysorders 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

LegDeliveryRuns onUse whenExample
Per-service-queue split (default)N enqueues, one per subscriberAny queue transportNo native broker, or you want the simplest mental modelfanout
Redis Streams broadcast1 XADD, N consumer groupsNode/Bun/Deno subscriberAlready on Redis/Upstash and want producer-side savingsbroadcast
SNS → SQS broadcast1 Publish, N SQS queuesNode/Bun/Deno subscriberAWS-native deploysns
Cloudflare DO router1 publish, router fans outCloudflare WorkersNo native CF broker; want producer-blindnessnotify-split
QStash push1 HTTP POST, N pushed deliveriesAny platform, incl. freezing functionsSubscriber is Vercel / Netlify / Lambdaqstash

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:

services/orders/heximon.config.ts
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:

services/orders/heximon.config.ts
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:

services/orders/heximon.config.ts
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:

services/orders/heximon.config.ts
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.

services/orders/heximon.config.ts
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():

services/orders/heximon.config.ts
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:

services/orders/heximon.config.ts
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:

services/billing/src/integration-events.controller.ts
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 deploy yet; the flagship notify-split app's DEPLOY.md is the recipe, and the router's own registry + fan-out logic is unit-tested against the real IntegrationEventRouterRoom Durable Object.
  • Redis Streams broadcast — verified with an in-process fake + real vp build artifacts; no live multi-service run yet.
  • SNS → SQS broadcast — verified with an in-process fake + real vp build artifacts; the live AWS round-trip is the sns example's DEPLOY.md (Terraform-provisioned topic + queues).
  • QStash push broadcast — verified with the in-process QStashSimulator + real vp build artifacts; the live Vercel/Netlify round-trip is the qstash example's DEPLOY.md.

See docs/live-deploy-verification.md for the dated ledger behind every "verified" claim in these docs.

See also

  • Integration Events — the typed IntegrationEvent producer, the IntegrationEventHandler consumer, 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.json generation, and the platform the DO router runs on.
Copyright © 2026