ServicesPubSub

PubSub

Ephemeral channel-based publish/subscribe: JSON messages published over HTTP fan out in real time to WebSocket subscribers. Nothing is persisted - a message reaches whoever is listening at that instant and is gone. Access is capability-based: every principal owns a private channel namespace and can share it through grants and rooms.

When to reach for it: real-time event fanout, live dashboards and tickers, cross-client signals ("refresh now"), tailing events during development - anywhere losing a message is acceptable.

When not to: durable delivery with acknowledgements, visibility timeouts, and dead-letter queues belongs to Message Queue; persistent per-user notifications belong to Inbox; shared mutable state with change subscriptions belongs to Live JSON.

Concepts

  • Ephemeral broadcast - no history, no replay. Publishing to a channel with zero subscribers succeeds and reports subscribers_notified: 0; the message is simply lost.
  • Channels are dot-segmented names - alphanumerics plus ., -, _, starting and ending alphanumeric, at most 128 characters, no consecutive special characters (a..b, a--b are rejected with 400 invalid_channel).
  • Namespaces - every principal owns the channels under u.{ns}., where {ns} is a stable, opaque 16-character id derived server-side. You learn yours when you mint a token.
  • Capability tokens - a session token alone cannot publish or subscribe (it fails with 403 channel_forbidden). You first mint a short-lived pub/sub token carrying your capabilities; platform admins and service accounts skip this and use their normal token.
  • Grants and rooms - a grant shares one of your channels with another user; a room (room.{name}) is a first-come claimed channel for shared topics that don't belong to any one user's namespace. Both are covered in Access control.

Mint a token, publish, subscribe

snug --output json pubsub token
{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs...",
  "expires_in": 600,
  "namespace": "5JY5XUIVDLNWX5WV"
}

Export the minted token and use your namespace as the channel prefix. The CLI's listen command subscribes over WebSocket and blocks until Ctrl+C:

export SNUG_API_TOKEN=<minted token>
NS=5JY5XUIVDLNWX5WV

snug pubsub listen -c "u.$NS.alerts" --raw &      # subscriber
snug pubsub publish -c "u.$NS.alerts" \
  -p '{"level":"critical","msg":"disk full"}' -t trace-1

The subscriber receives the frame (captured live; --raw prints raw WebSocket text frames, ideal for piping to jq):

{
  "type": "pubsub_message",
  "channel": "u.5JY5XUIVDLNWX5WV.alerts",
  "message_id": "msg_rewwTuxqjWBs",
  "payload": { "level": "critical", "msg": "disk full" },
  "timestamp": 1787893754,
  "headers": { "trace_id": "trace-1" },
  "publisher_id": "docs-wave"
}

timestamp is Unix seconds; headers appears only when the publish set one (-t sets trace_id). The stream also carries ping/pong heartbeats.

Over HTTP, publishing is POST /api/v1/pubsub/publish/{channel}:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"payload": {"level": "critical", "msg": "disk full"}}' \
  http://localhost:4000/api/v1/pubsub/publish/u.$NS.alerts
{
  "status": 200,
  "msg": "OK",
  "data": {
    "message_id": "msg_WqCrAAMEKrZt",
    "channel": "u.5JY5XUIVDLNWX5WV.alerts",
    "timestamp": 1787893754,
    "subscribers_notified": 1
  }
}

The WebSocket endpoint is /ws/pubsub/{channel} at the server root (not under /api/v1), authenticated with a Bearer header or a ?token= query parameter for browsers. An unauthorized subscribe is rejected with 403 at the handshake, before the socket ever opens.

publish-batch sends one payload to up to 100 channels in a single call (repeat -c per channel) and reports a per-channel message_id and subscriber count:

snug pubsub publish-batch -c "u.$NS.alerts" -c "u.$NS.audit" -p '{"deploy":"v42"}'

Inspect channels

snug pubsub channels                  # active channels your capabilities cover
snug pubsub info "u.$NS.alerts"       # subscriber count (positional name, not -c)
snug pubsub stats "u.$NS.alerts"      # Redis vs WebSocket subscriber split
{
  "name": "u.5JY5XUIVDLNWX5WV.alerts",
  "redis_subscribers": 1,
  "websocket_subscribers": 1,
  "total_subscribers": 1,
  "is_active": true
}

channels lists only channels that currently have a subscriber, filtered to the ones your capabilities allow you to subscribe to - with a bare session token the list is []. info and stats never return 404: a channel "exists" only as long as someone is subscribed, so an idle name reports zero counts and is_active: false. Asking about a channel outside your capabilities is 403 channel_forbidden.

Behaviors and gotchas

  • Mint first. Publishing with a plain session token fails with 403 channel_forbidden ("missing 'publish' capability") even on your own namespace - capabilities live only in the minted token.
  • subscribers_notified counts Redis fanout, not end clients: one server node multiplexing many WebSocket subscribers counts once. Use stats for the end-client picture.
  • Revocation is not instant. A minted token keeps its capabilities until it expires (600 seconds by default), so a revoked grant dies at the grantee's next mint, not immediately.
  • Batch publishes are all-or-nothing on authorization - one channel you cannot publish to rejects the whole batch with 403, publishing nothing.

Limits and configuration

Channel names are capped at 128 characters and batch publishes at 100 channels. The namespace-deriving secret, the 600-second capability token TTL, and publish connection timeouts are set by the PUBSUB_* variables in the PubSub CONFIG reference.

Reference

On this page