@aws-blocks/bb-realtime

中文文档
0.5.0发布于 2026/11/02Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 6.3万

Real-time pub/sub messaging backed by API Gateway WebSocket + DynamoDB.

原始使用文档
文档目录23 节
  1. Quick Start
  2. API
  3. Namespaces
  4. Channel Handle
  5. RealtimeSubscription
  6. Usage Patterns
  7. Server-Side Publish via API
  8. Returning a Channel Handle (Authorization Gate)
  9. Server-Side Subscribe
  10. Multiple Subscriptions Share a Connection
  11. Handling Auth Failures
  12. Handling Disconnects & Reconnects
  13. Schema Validation
  14. Error Constants
  15. Best Practices
  16. Scaling & Cost (AWS, US East)
  17. Per-Publish Cost (to N subscribers)
  18. Connection Cost
  19. Publish Latency
  20. Limits
  21. Local Development
  22. Channel Names
  23. See Also

@aws-blocks/bb-realtime

Real-time pub/sub messaging backed by API Gateway WebSocket + DynamoDB.

When to use: Push data from the server to connected clients in real time — chat messages, live notifications, dashboard updates, collaborative state sync.

When NOT to use: If you need request-response APIs, use ApiNamespace. If you need durable message queuing with guaranteed delivery, use AsyncJob.

Scaling envelope: Best suited for channels with tens to low-thousands of concurrent subscribers. Publish latency scales linearly with subscriber count (~100ms for 1,000 subscribers). For channels with 10K+ subscribers, use explicit fan-out patterns (e.g., sharded AsyncJob). For 100K+ broadcast audiences, consider a dedicated WebSocket fleet.

Design & mock parity details: DESIGN.md

Quick Start

import { Realtime } from '@aws-blocks/bb-realtime';
import { z } from 'zod';

const rt = new Realtime(scope, 'collab', {
  namespaces: {
    cursors: Realtime.namespace(z.object({ userId: z.string(), x: z.number(), y: z.number() })),
    chat: Realtime.namespace(z.object({ sender: z.string(), text: z.string() })),
  },
});

API

Namespaces

Define typed namespaces using Realtime.namespace(schema). The schema provides both the TypeScript type (inferred) and runtime validation on publish.

The Realtime instance exposes three methods, all keyed by namespace name (type-checked with full autocomplete):

MethodReturnsDescription
rt.publish(namespace, channel, data)Promise<void>Broadcast data to all subscribers (best-effort). Validates against schema.
rt.getChannel(namespace, channel)Promise<RealtimeChannel<T>>Get a channel handle (async — await it). Return from API methods for client hydration.
rt.subscribe(namespace, channel, handler)() => voidServer-side subscribe. Returns unsubscribe function.

Runtime only. These methods (publish, subscribe, getChannel) run at request time — call them inside an ApiNamespace method, RawRoute handler, job handler, or a runtime script, not at the top level of your aws-blocks/index.ts. Top-level code runs during CDK synth, where the block resolves to its infrastructure construct, whose data methods are guards: a top-level call throws an Error reading Realtime.<method>() cannot be called during CDK synth. Data methods run at request time — …. To publish seed data, do it from inside a handler or a separate runtime script. Constructing the block at module scope is fine; only method calls must move into handlers.

Channel Handle

getChannel() returns a Promise<RealtimeChannel<T>> — await it to get a subscribe-only handle that serializes via toJSON() for client transfer:

MethodReturnsDescription
subscribe(handler)RealtimeSubscriptionListen for messages (simple form).
subscribe({ onMessage, onDisconnect?, onReconnect?, refresh? })RealtimeSubscriptionListen for messages with disconnect + reconnect handling (and optional token re-minting on reconnect via refresh).
toJSON()RealtimeChannelDescriptorTransferable serialization (called automatically by JSON.stringify).

Channel handles do not have a publish() method. Publishing always goes through rt.publish() (server-side) so that authorization logic stays in your code.

RealtimeSubscription

Returned by subscribe() on a channel handle:

PropertyTypeDescription
unsubscribe()() => voidStop receiving messages.
establishedPromise<void>Resolves when the server confirms the subscription. Rejects on auth failure (e.g., invalid token).
connectionWebSocket | undefinedThe underlying WebSocket. Present on client-side subscriptions. Multiple channels on the same endpoint share a single connection.

Always await sub.established before relying on the subscription — this ensures the WebSocket handshake and server-side authorization have completed.

Usage Patterns

Server-Side Publish via API

export const api = new ApiNamespace(scope, 'api', (context) => ({
  async sendMessage(roomId: string, text: string) {
    const user = await auth.requireAuth(context);
    await rt.publish('chat', roomId, { sender: user.id, text });
    return { sent: true };
  },
}));

Returning a Channel Handle (Authorization Gate)

The recommended pattern for client subscriptions. Authorization happens in your API method — the channel handle is only returned if the user is allowed:

export const api = new ApiNamespace(scope, 'api', (context) => ({
  async joinRoom(roomId: string) {
    const user = await auth.requireAuth(context);
    if (!canAccessRoom(user, roomId)) throw new Error('Forbidden');
    return rt.getChannel('chat', roomId);
  },
}));

Client side:

const channel = await api.joinRoom('room-1');
const sub = channel.subscribe((msg) => {
  console.log(msg.sender, msg.text); // fully typed
});
await sub.established; // wait for server confirmation

Server-Side Subscribe

The server can subscribe to channels too — useful for aggregation, logging, or triggering side effects:

export const api = new ApiNamespace(scope, 'api', (context) => ({
  async monitorRoom(roomId: string) {
    const ch = await rt.getChannel('chat', roomId);
    const sub = ch.subscribe((msg) => {
      console.log(`[${roomId}] ${msg.sender}: ${msg.text}`);
    });
    await sub.established;
    return { monitoring: true };
  },
}));

On AWS, server-side subscribe uses a real WebSocket connection, so it receives messages regardless of which Lambda invocation published them. Locally, it uses an in-process EventEmitter.

Multiple Subscriptions Share a Connection

When a client subscribes to multiple channels, they share a single WebSocket connection:

const ch1 = await api.joinRoom('room-1');
const ch2 = await api.joinRoom('room-2');

const sub1 = ch1.subscribe(handler1);
const sub2 = ch2.subscribe(handler2);

await sub1.established;
await sub2.established;

// sub1.connection === sub2.connection (same WebSocket)

Messages are routed to the correct handler — room-1 messages only go to handler1, room-2 only to handler2.

Handling Auth Failures

If a channel's token is invalid or expired, established rejects:

const sub = channel.subscribe(handler);
try {
  await sub.established;
} catch (err) {
  if (err.name === 'ConnectionFailedException') {
    // token was rejected — re-fetch the channel from the API
  }
}

A failed subscribe does not kill other subscriptions on the same connection.

Handling Disconnects & Reconnects

API Gateway caps a WebSocket at a 2-hour max connection duration and a 10-minute idle timeout, so a long-lived subscription will be dropped and re-established over its lifetime. The client transport handles this for you: on an unexpected drop it transparently reconnects with exponential backoff and resubscribes every active channel (replaying its stored token), so you do not need to re-subscribe manually. A ~9-minute keep-alive ping avoids the idle timeout.

Use the options form of subscribe() to observe the lifecycle:

const sub = channel.subscribe({
  onMessage: (msg) => { console.log(msg); },
  onDisconnect: (reason) => {
    // reason: 'client' | 'timeout' | 'error' | 'unknown'
    // Fires on EVERY drop (not just the first). Filter out 'client' (your own unsubscribe()).
    if (reason === 'client') return;
  },
  onReconnect: () => {
    // Fires once after the transport has reconnected AND the server has re-confirmed
    // the resubscribe (after the corresponding onDisconnect). Backfill any messages
    // missed during the gap here — WebSocket pub/sub is not durable, so re-read your
    // authoritative store (e.g. the conversation/record) rather than trusting the channel.
  },
});

onDisconnect fires for all disconnects, including user-initiated unsubscribe() (reason 'client'), and on every subsequent drop of the same logical subscription.

If a resubscribe is ultimately rejected (a stored token that has passed its ~1h/~2h TTL, or a revoked channel) or retries are exhausted, the transport surfaces it as onDisconnect('error'). Without a refresh callback (below), that is the fallback point to re-fetch the channel and re-subscribe manually.

Outliving the token TTLs with refresh

To keep a subscription alive past the token TTLs without hand-rolling re-subscription, pass a refresh callback:

const sub = channel.subscribe({
  onMessage: (msg) => { console.log(msg); },
  // Called before EACH reconnect to re-mint a fresh descriptor (new connect + channel
  // token), so the subscription outlives the channel (~1h) / connect (~2h) token TTLs.
  // Return the RAW descriptor (the wire object with `__blocks`/token fields), e.g. your
  // server's `channel.toJSON()` with `__blocks` stripped, then re-add the discriminant.
  refresh: async () => ({ ...(await fetchFreshDescriptor()), __blocks: 'realtime/channel' }),
});

refresh: () => Promise<RealtimeChannelDescriptor> is invoked before the socket is reopened on each reconnect; the fresh connect token builds the new socket URL and the fresh channel token is replayed on resubscribe, so a drop crossing the ~1h/~2h boundary recovers transparently instead of giving up. Each channel multiplexed on the connection re-mints its own token in parallel; a refresh that rejects (or exceeds an internal timeout) falls back to the stored token, and only an all-channels-failed outcome surfaces onDisconnect('error'). The server method behind refresh re-issues a channel token, so it must apply the same authorization as the method that issued the original channel.

Reconnect without refresh spans only the connect token's ~2h life. With no refresh, the reconnect replays the stored connect token (~2h TTL, matching API Gateway's 2h max connection); past 2h the $connect handshake is rejected and reconnect gives up with a terminal onDisconnect('error'). Provide refresh (above) to re-mint fresh tokens on each reconnect and outlive that window; otherwise treat the terminal onDisconnect('error') as the cue to re-fetch a fresh channel handle and re-subscribe manually.

Schema Validation

Every publish() validates against the schema at runtime:

import { isBlocksError } from '@aws-blocks/core';
import { RealtimeErrors } from '@aws-blocks/bb-realtime';

try {
  await rt.publish('chat', 'room-1', { sender: 123 }); // wrong type
} catch (e: unknown) {
  if (isBlocksError(e, RealtimeErrors.ValidationFailed)) {
    // data failed schema validation
  }
}

Error Constants

import { RealtimeErrors } from '@aws-blocks/bb-realtime';

RealtimeErrors.ValidationFailed  // data failed schema validation on publish
RealtimeErrors.PublishFailed     // Publish fan-out failed (AWS only)
RealtimeErrors.ConnectionFailed  // WebSocket connection or subscribe rejected

Best Practices

  • Await established before publishing or relying on a subscription — don't race the WebSocket handshake.

  • Subscribe before you publish. Realtime is fire-and-forget with no message buffering (local dev and AWS alike): a subscriber only receives messages published after its subscription registers (await sub.established). If a server-side publisher (e.g. an AsyncJob or Agent stream) may fire before the client subscribes, the client must subscribe first, then trigger the publisher, and backfill any earlier messages from a durable source (DB/history).

  • Publish through the API, not the channel handle — keeps authorization logic in one place.

  • Use channels for dynamic scoping — room-123, user-456, game-abc. One namespace, many channels.

  • Keep payloads small — large messages increase latency and cost. Max 128 KiB (131,072 bytes) per published message (including wire envelope).

  • One Realtime instance per domain — use multiple namespaces within it for different message types (cursors, chat, presence).

  • Unsubscribe when done — especially in components that mount/unmount. Leaked subscriptions hold the WebSocket open.

  • Delivery is best-effort — publish() sends to all connected subscribers in parallel. If delivery to an individual connection fails, the failure is logged and the rest continue. Stale connections are cleaned up automatically. This is similar to UDP: fire-and-forget per connection.

  • Large fan-out — publish() awaits delivery to all subscribers before returning. For channels with tens of subscribers, this adds negligible latency. For larger audiences, offload to AsyncJob so the API response isn't blocked:

    const broadcast = new AsyncJob(scope, 'broadcast', {
      schema: z.object({ channel: z.string(), data: cursorSchema }),
      handler: async ({ channel, data }) => { await rt.publish('cursors', channel, data); },
    });
    
    // In your API method:
    await broadcast.submit({ channel: roomId, data: cursor }); // returns immediately
    
  • Broadcast scale (10K+ subscribers) — a single Lambda invocation hitting postToConnection for every subscriber will hit API Gateway TPS limits (~10K default). For this scale, shard the fan-out across multiple AsyncJob invocations, each responsible for a slice of subscribers. This bridges the gap while you evaluate a dedicated WebSocket fleet for true broadcast workloads.

  • Test auth flows in sandbox — use npm run test:e2e:sandbox to validate the full deployed auth flow end-to-end.

Scaling & Cost (AWS, US East)

Cost scales linearly with messages delivered. The API Gateway message fee dominates.

Per-Publish Cost (to N subscribers)

OperationCost
DynamoDB: query GSI for subscriber list~$0.0000001 (negligible)
API Gateway: deliver message × NN × $0.000001
Total per publish≈ N × $0.000001

The publish call itself is a Lambda invocation, not a WebSocket message — no API Gateway charge for the inbound side. DynamoDB lookup is <0.01% of cost.

SubscribersCost per publishCost per 1M publishes
1$0.000001$1.00
10$0.00001$10.00
100$0.0001$100.00

Connection Cost

ComponentRate
Connection-minutes$0.25 per million
Keep-alive pings (~7/hr)~$0.000005/hr (DynamoDB reads + writes)
Per connection-hour~$0.00002

1,000 connections online 24/7 ≈ $18/month. 10,000 connections × 8 hrs/day ≈ $48/month.

Publish Latency

publish() delivers messages in parallel, limited by the SDK's HTTP connection pool (50 concurrent sockets by default). Each postToConnection takes ~5-10ms with TCP keep-alive within the same region.

SubscribersEstimated publish latency
10~5-10ms (single wave)
50~5-10ms (single wave)
200~20-40ms
500~50-100ms
1,000~100-200ms

The AWS SDK retries throttled requests automatically (3 retries with exponential backoff).

Limits

LimitValueEnforcedNotes
Channel path (full)1024 bytesYes — both local and AWSDynamoDB sort key limit. Includes {fullId}/{namespace}/ prefix.
Message size (published)128 KiB (131,072 bytes)Yes — both local and AWSAPI Gateway WebSocket logical message limit (frames are reassembled; the 32 KB quota is a per-frame limit, not a per-message one). Includes wire envelope.
Max connection duration2 hoursNo — API Gateway hard limitTransparent reconnect; use refresh to outlive it
Idle timeout10 minutesNo — API Gateway hard limitClient middleware sends keep-alive pings
Account-level API TPS10,000/secNo — API Gateway hard limitShared across all API Gateway usage; raisable
New connections500/secNo — API Gateway hard limitPer account per region; raisable

Local Development

In local dev (npm run dev), Realtime uses an in-process EventEmitter with a WebSocket bridge to browser clients. The local WebSocket server sends subscribe_success on valid subscribes and error on invalid tokens. Schema validation runs identically to AWS.

Messages are ephemeral — no persistence across restarts.

Channel Names

Channel names are scoped by the Realtime instance and namespace: {fullId}/{namespace}/{channel}. The channel argument you pass to publish(), subscribe(), and getChannel() is the user-facing portion.

Recommended limits:

  • Keep channel names under 256 characters. The full channel path (including instance ID and namespace) is stored as a DynamoDB sort key and included in every WebSocket message.
  • Use short, descriptive identifiers: room-123, user-456, game-abc.
  • Avoid embedding large payloads or UUIDs longer than necessary in channel names.

Hard limits:

  • The full channel path (including {fullId}/{namespace}/ prefix) must fit within a DynamoDB sort key (1024 bytes). This is enforced at runtime in both local dev and AWS — exceeding it throws ValidationFailedException.
  • Published messages (including the wire envelope with channel path) must fit within API Gateway's 128 KiB logical WebSocket message limit. API Gateway's separate 32 KB quota is a frame limit — the service reassembles fragmented frames, so it is not the per-message cap. This is enforced at runtime in both environments.
  • There is no enforced character limit on the user-facing channel name, but excessively long names increase DynamoDB item sizes (affecting read/write costs) and WebSocket message sizes (affecting delivery latency and billing, since messages are metered in 32 KB increments).

The previous AppSync Events implementation had a 5-segment × 50-character channel name limit. That restriction no longer applies.

See Also

  • USE-CASES.md — Common use cases, scaling patterns, and industry examples

版本与发布记录

@aws-blocks/bb-realtime 共发布 14 个版本,首次发布于 2026/06/16;过去 12 个月发布了 14 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.5.0latest2026/11/025 天前
0.4.02026/10/1622 天前
0.0.0-canary.esql-canary.c93c81b.1791235863canary-esql-canary2026/10/061 个月前
0.3.02026/10/051 个月前
0.0.0-canary.esql-canary.f7f39ac.17909891272026/10/031 个月前
0.0.0-canary.ci-publish-canary-npm.f53dfdc.1790988617canary-ci-publish-canary-npm2026/10/031 个月前

关于 @aws-blocks/bb-realtime 的常见问题

@aws-blocks/bb-realtime 是什么?

Real-time pub/sub messaging backed by API Gateway WebSocket + DynamoDB.

如何安装 @aws-blocks/bb-realtime?

在项目目录运行 npm install @aws-blocks/bb-realtime,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:constructs(^10.6.0)、aws-cdk-lib(^2.257.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@aws-blocks/bb-realtime 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@aws-blocks/bb-realtime 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@aws-blocks/bb-realtime 可以商用吗?

@aws-blocks/bb-realtime 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@aws-blocks/bb-realtime 的下载量有多少?

根据 npm 官方下载统计,@aws-blocks/bb-realtime 在 2026/10/07 – 2026/11/05 这 30 天内被下载 63,218 次,日均约 2,107 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@aws-blocks/bb-realtime 还在维护吗?

最新版本 0.5.0 发布于 2026/11/02(5 天前);至今共发布 14 个版本,其中过去 12 个月发布了 14 个;首次发布于 2026/06/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。