graphql-transport-ws, Apollo-compatible).
One engine binary runs both gateways — select them with
VS_ROLES=ws,graphql (either alone is fine too). This path shares
nothing with the CDC source/sink; it’s a separate role on the same
binary.
Try it: the real-time demo (GraphiQL) brings
up NATS + the gateways in one
docker compose up and lets you watch typed
subscriptions update live in the browser — including an optional command
that streams a changing event every second.The event model
Subjects
Events are addressed byevent + id — the event name and the
instance id, with the id last:
<event>is the event name — one or more segments, mixed case allowed (orderStatusChanged,orders.order.statusChanged).<id>is always the last segment, so a single instance is directly addressable and the whole event class is wildcard-able.
vs.t.<tenant>. for them, so one tenant can never receive another’s
events:
Envelope
Every event is a small envelope (thedata field is opaque JSON you
define; actor/metadata are optional):
Publishing
Publishers send the full envelope to an anchored broker subject. With the NATS CLI:received_at when publishing. Publisher services
should derive the tenant from their authenticated workload context rather than
accepting it from an end user.
The repository contains PublishInput in ventstream-protocol as a reference
for building the envelope. No VentStream npm publisher package is distributed
in the current release. For Redis, append the same envelope to the tenant stream
using the Redis wire contract.
Subscribing
- Apollo Client (GraphQL)
- Raw WebSocket
The GraphQL gateway speaks A generic With a subscriptions manifest you also
get typed fields (e.g.
graphql-transport-ws, so the standard
Apollo subscription link works unchanged. Auth + tenant travel in the
connection-init payload:events(subject:) field is always available:orderStatusChanged(orderId:)) that Apollo codegen
turns into fully-typed hooks.Typed GraphQL subscriptions
To expose typed fields instead of only the genericevents(subject:),
declare them in a GraphQL SDL file (VS_GRAPHQL_SCHEMA=…) — the
recommended way. Annotate each field with @vsSubscribe(subject:) (the
event it maps to, id last); the friendly {argName} placeholder is
filled from the field’s args. Return the built-in Event (opaque
envelope) or a custom type whose fields take an optional @source(from:)
(default $data.{field}):
$data.PATH (from the event’s opaque data),
$event.FIELD (envelope field: id, tenant, event, entityId,
occurredAt, receivedAt, subject), {argName} (a subscription
argument). The generic events(subject:) field stays available alongside
the typed ones.
How each kind resolves:
$event.FIELDreads the envelope from a fixed field set, and either casing works —$event.entityIdand$event.entity_idare the same field. (The envelope is snake_case on the wire; camelCase just matches the GraphQL side.)$data.PATHis an exact dot-path into the opaquedataJSON. Keys are matched character-for-character — write them exactly as you publish them ($data.user_namefor auser_namekey), since the gateway never transformsdata. A path that doesn’t match resolves tonull.- Omitting
@sourcedefaults to$data.{fieldName}— the GraphQL field name used verbatim as thedatakey. So name a field to match itsdatakey and it needs no@source; set one explicitly when they differ (e.g. a camelCase fielduserNameover a snake_case key,@source(from: "$data.user_name")).
The same model can be authored as a YAML manifest via
VS_GRAPHQL_SUBSCRIPTIONS (the legacy alternative), and an entity_ref
return type emits a Federation v2 reference the router stitches with the
owning subgraph. Set VS_GRAPHQL_PLAYGROUND=1 to browse it all in an
in-browser GraphiQL at /graphiql.Reading a subscription as its publish contract
The gateway doesn’t constrain publishers — but a typed subscription is the publish contract, read in reverse. Everything a publisher must send is right there in the SDL: the@vsSubscribe subject tells you where to
publish, and the @source expressions tell you which fields the event
must carry. Read it like this:
Take orderStatusChanged above:
So the rule of thumb: the
data you must publish = the set of
$data.* paths the subscription reads (here, just status). Everything
else is envelope-supplied. Same contract, either transport:
Any NATS client works. Build the full envelope and publish it to the composed
subject:
orderEvents(orderId:) : Event! variant reads nothing from data
(it hands the whole envelope back), so its only requirement is the same
subject — the data shape is entirely up to you.
This stays a convention, not enforcement: if you omit status, the
publish still succeeds and the subscriber’s status resolves null (or
errors only if the SDL marked it non-null). Keeping publisher and
subscriber in agreement is what the SDL documents.
Delivery providers
In JetStream mode the ws role bootstraps the stream that the GraphQL
role reads from, so running
VS_ROLES=ws,graphql with
VS_WS_JETSTREAM=1 is the usual pairing.
The stream is a self-bounding buffer
Subscribers default to live-only (New); a client can resume events it
missed on reconnect by sending a cursor (see Resuming after a
disconnect) — but only as far back as the
buffer still holds. So the stream isn’t a durable log; it’s a short live +
resume buffer. The engine creates it with self-bounding limits so it can’t
grow without bound and needs no operator storage sizing:
RetentionPolicy::Limits+discard: old→ JetStream continuously evicts the oldest messages the instant a limit is hit. That is the “purge old data” behavior, done per-message by the broker — no engine purge loop, no sawtooth.- Defaults:
max_age=10m,max_bytes=512 MiB(a ceiling, not a reservation). Set nothing and the stream stays tiny at any publish rate. - For throughput, set
VS_WS_JS_STORAGE=memory— RAM-backed and faster; the 512 MiB ceiling means it can’t OOM NATS, and losing it on a NATS restart drops only the live + resume buffer, never acked-and-delivered data. Tune withVS_WS_JS_MAX_AGE_SECS/VS_WS_JS_MAX_BYTES/VS_WS_JS_MAX_MSGS.
Resuming after a disconnect
In JetStream or Redis Streams mode a reconnecting client can replay missed events. Each event carries a provider-neutralcursor; persist it only after
successful processing. GraphQL operations return it as the optional
resumeFromCursor field argument. Native WebSocket clients return it as
resume_from_cursor in hello. The gateway starts just after that cursor.
Omit it on the first connection for live-only delivery.
For a complete Apollo Client implementation with serialized async handlers,
durable checkpoints, reconnect state, and terminal error handling, follow
Reliable Apollo subscriptions.
- Apollo Client (GraphQL)
- Raw WebSocket
Every generic and typed subscription accepts an optional
Pass
resumeFromCursor: String argument. Maintain one cursor for each logical
operation:null on the first subscription. After successful processing, save
that operation’s returned cursor and use it when recreating the operation.
Multiple independently checkpointed operations may share one socket.The gateway auto-adds the reserved resumeFromCursor argument to generic
and typed subscription fields. It adds cursor: String! and the
compatibility seq: String! to inline typed result types when the authored
SDL does not already define those names. For reliable replay, avoid authored
cursor or seq fields because their application mappings take precedence
for backward compatibility.resume_from_cursor in connection_init remains a compatibility fallback
for one-operation clients or a client with one globally coordinated
checkpoint. An operation argument takes precedence.- GraphQL operation-scoped replay. Live-only GraphQL operations share the
connection source. A GraphQL operation with a cursor opens an isolated,
subject-filtered replay session, so an operation attaching first cannot
acknowledge replay that belongs to another operation. The client must
recreate each operation with its latest cursor after reconnect because
graphql-wsotherwise reuses the variables from the original subscribe message. - Native WebSocket connection-scoped replay. One durable consumer serves every native subscription on a connection. Multiple reliability-critical native patterns therefore require one coordinated checkpoint or separate sockets.
- Retention window only. Resume reaches back only as far as the stream
still holds (
max_age/max_bytes). A cursor older than the earliest retained event is rejected withresume_expired/ GraphQLRESUME_EXPIRED; a cursor beyond the high watermark is rejected as invalid. Validation prevents a stale cursor from silently becoming live-only at consumer creation. Retention continues advancing during replay, so size the window for the maximum outage plus worst-case catch-up time and alert before replay lag approaches the boundary. - Core mode has no durable cursor and does not support resume.
- Large gaps replay through bounded local buffers. A client that cannot keep up receives a terminal lag error and must recreate the operation or connection from its last successfully processed cursor.
Per-connection consumers & cleanup
In JetStream mode each WS connection gets its own durable consumer namedvs-t-<tenant>-p-<pod>-c-<connection_id>. Because durable consumers are
server-side state, the engine cleans them up three ways so none leak:
1
Drop guard (immediate)
When the connection closes gracefully, an RAII guard deletes the
consumer right away.
2
Inactive threshold (server-side)
JetStream auto-deletes a consumer after
VS_WS_JS_INACTIVE_THRESHOLD_MS
of inactivity — catches kill -9 / OOM where the drop guard never ran.3
Reaper (periodic)
A sweep every
VS_WS_JS_REAPER_INTERVAL_MS deletes any consumer for
this pod whose connection isn’t in the live registry — the backstop.Capacity: connection cap, readiness & scale-out
Each connection costs ~165 KiB (mailbox + per-connection JetStream consumer). With no ceiling a surge climbs RSS linearly until the pod OOMKills — which drops every established connection at once. Four layers keep a pod safe and let the fleet grow:- Hard cap (
VS_WS_MAX_CONNS). A WS upgrade past the cap is rejected with503 Service Unavailable+Retry-Afterbefore the consumer and mailbox are allocated. The slot is reserved atomically at admission, so a burst of simultaneous upgrades can’t overshoot it — at mostVS_WS_MAX_CONNSconnections occupy the pod. Size it to the memory limit:~(limit − base) / 165 KiB. - Startup readiness.
/readyzremains503until every enabled gateway has connected to NATS/JetStream, loaded its schema or manifest, and bound its traffic listener. The health server does not create synthetic WebSocket connections or consumers; each role reports its own internal initialization boundary. - Capacity readiness.
/readyzreturns503at 90 % of the cap — below the hard reject — so the load balancer stops routing new connections to a near-full pod while it still has headroom. Existing connections are untouched, and/healthzstays200(a full pod is alive, not to be restarted). - Memory HPA. The gateway scales on memory utilization, not CPU — idle connections are RAM-bound, not CPU-bound. jemalloc’s background decay keeps RSS tracking the live working set, so the metric is honest.
Clients back off correctly. A
503 reject surfaces to the client as a
connection close; the SDK’s jittered exponential backoff (capped at 30 s)
retries and lands on a pod with capacity — and on ready resets its
backoff. This is better than no cap: the alternative, OOMKill, closes
thousands of connections abnormally (code 1006) at once with no
back-off hint — the real reconnect storm. The cap turns that into a
graceful, jittered retry for the few clients caught in the readiness-
propagation window.Key environment variables
Full list in the engine env reference.