Skip to content

Pattern: WebSocket API

Manage persistent client connections with three dedicated route handlers ($connect, $disconnect, $default) backed by DynamoDB for connection tracking and EventBridge for server-initiated push.

The Pattern

typescript
// src/lambdas/websocket/connect.ts
import { defineLambda, defineWebSocketHandler } from '@mantleframework/core'
import type { APIGatewayProxyWebsocketHandlerV2 } from 'aws-lambda'
import { storeConnection } from '@mantleframework/aws'
import { logInfo } from '@mantleframework/observability'

defineLambda({ env: ['CONNECTIONS_TABLE_NAME'] })

const ws = defineWebSocketHandler({ routeKey: '$connect', operationName: 'WsConnect' })
export const handler: APIGatewayProxyWebsocketHandlerV2 = ws(async ({ connectionId }) => {
  await storeConnection(connectionId, undefined, 7200) // 2-hour TTL (API Gateway hard limit)
  logInfo('WebSocket client connected', { connectionId })
  return { statusCode: 200 }
})
typescript
// src/lambdas/websocket/default.ts — handle client-initiated messages
import { defineLambda, defineWebSocketHandler } from '@mantleframework/core'
import type { APIGatewayProxyWebsocketHandlerV2 } from 'aws-lambda'
import { postToConnection } from '@mantleframework/aws'
import { logInfo } from '@mantleframework/observability'

defineLambda({})

const ws = defineWebSocketHandler({ routeKey: '$default', operationName: 'WsDefault' })
export const handler: APIGatewayProxyWebsocketHandlerV2 = ws(async ({ connectionId, body }) => {
  const message = body as { action?: string } | null
  if (message?.action === 'ping') {
    await postToConnection(connectionId, JSON.stringify({ type: 'pong' }))
    return { statusCode: 200 }
  }
  logInfo('Unknown WebSocket action', { connectionId, action: message?.action })
  return { statusCode: 200 }
})
typescript
// src/lambdas/eventbridge/BroadcastUpdate/index.ts — server-push via EventBridge
import { defineEventBridgeHandler, defineLambda } from '@mantleframework/core'
import { broadcastToConnections } from '@mantleframework/aws'
import { logInfo } from '@mantleframework/observability'
import { BroadcastDetailSchema } from '../../../schemas/eventbridge.js'

defineLambda({ env: ['CONNECTIONS_TABLE_NAME'] })

const eb = defineEventBridgeHandler({ detailTypes: ['BroadcastUpdate'], detailSchema: BroadcastDetailSchema, timeout: 30 })
export const handler = eb(async ({ detail }) => {
  await broadcastToConnections({ type: 'update', resource: detail.resource })
  logInfo('Broadcast sent', { resource: detail.resource })
  return { resource: detail.resource }
})

How It Works

Three Lambdas handle the WebSocket lifecycle: $connect stores the connectionId in DynamoDB with a 2-hour TTL (matching API Gateway's hard limit), $disconnect removes it, and $default handles client-initiated messages by action type. Server-initiated pushes are decoupled entirely — an EventBridge BroadcastUpdate event triggers a fourth Lambda that iterates all stored connections and calls broadcastToConnections, which wraps the API Gateway Management API. This design means any service in the system can trigger a broadcast simply by emitting an event without needing direct access to connection state.

Real-World Usage

Configuration

typescript
// mantle.config.ts
export default defineConfig({
  name: 'lifegames-portal',
  websocket: {
    routeSelectionExpression: '$request.body.action',
    stageName: 'live',
    throttlingBurstLimit: 50,
    throttlingRateLimit: 25,
  },
  dynamodb: [{
    name: 'connections',
    hashKey: 'connectionId',
    attributes: [{ name: 'connectionId', type: 'S' }],
    ttlAttribute: 'ttl',
  }],
  eventbridge: { bus: 'lifegames-portal' },
})

The websocket block tells Mantle's infra generator to create an API Gateway v2 WebSocket API with the given throttle limits and route selection expression. The dynamodb entry creates the connections table; CONNECTIONS_TABLE_NAME is automatically injected into Lambdas that declare it in defineLambda({ env: [...] }).

Variations

  • Authenticated connect: Validate a bearer token in the $connect handler before calling storeConnection — return { statusCode: 401 } to reject the upgrade.
  • Per-user connection tracking: Pass user metadata as the second argument to storeConnection to associate connections with user IDs for targeted pushes.
  • Direct postToConnection: Use postToConnection from @mantleframework/aws in any Lambda (not just $default) to push to a specific connection ID without broadcasting to all clients.