Skip to content

Chat Tutorial: Polish & Deploy ​

The chat works, but it has no security. Anyone can impersonate any username by crafting their own RPC calls, and a single client could flood the server with messages. In this final chapter, you'll add authentication middleware to procedures, rate limiting, error handling, and deploy the app.

Auth Middleware ​

Right now the procedures trust whatever username the client sends. Let's enforce that the username comes from the server-side cookie instead.

Create an auth middleware that extracts the username from the request:

ts
// app/rpc/middleware.ts
import { procedure, RPCError } from '@ereo/rpc'
import type { BaseContext } from '@ereo/rpc'

// Extract username from cookie and add to context
export const authed = procedure.use(async ({ ctx, next }) => {
  const request = ctx.request
  const cookieHeader = request?.headers?.get('Cookie') || ''
  const match = cookieHeader.match(/username=([^;]+)/)
  const username = match ? decodeURIComponent(match[1]) : null

  if (!username) {
    return {
      ok: false,
      error: { code: 'UNAUTHORIZED', message: 'Must be logged in to chat' },
    }
  }

  return next({ ...ctx, username })
})

Rate Limiting ​

Add rate limiting to prevent message spam. @ereo/rpc includes a built-in rate limiter:

ts
// app/rpc/middleware.ts (continued)
import { rateLimit } from '@ereo/rpc'

// 30 messages per minute per user
export const messageRateLimit = rateLimit({
  limit: 30,
  windowMs: 60 * 1000,
  keyFn: (ctx) => {
    // Rate limit by username from cookie
    const cookieHeader = ctx.request?.headers?.get('Cookie') || ''
    const match = cookieHeader.match(/username=([^;]+)/)
    return match ? decodeURIComponent(match[1]) : 'anonymous'
  },
  message: 'Slow down! You can send 30 messages per minute.',
})

// 10 typing events per 10 seconds
export const typingRateLimit = rateLimit({
  limit: 10,
  windowMs: 10 * 1000,
  keyFn: (ctx) => {
    const cookieHeader = ctx.request?.headers?.get('Cookie') || ''
    const match = cookieHeader.match(/username=([^;]+)/)
    return match ? `typing:${decodeURIComponent(match[1] || 'anon')}` : 'typing:anon'
  },
  message: 'Typing indicator rate limited.',
})

Update Procedures with Middleware ​

Refactor the procedures to use the auth middleware. The username now comes from ctx.username instead of input.username:

ts
// app/rpc/procedures.ts (updated)
import { procedure } from '@ereo/rpc'
import { store } from '~/lib/store'
import { emit, subscribe as subscribeToRoom } from '~/lib/events'
import { authed, messageRateLimit, typingRateLimit } from './middleware'
import type { ChatEvent } from '~/lib/events'

const pub = procedure

// Public queries — no auth needed
export const health = pub.query(() => ({
  status: 'ok',
  rooms: store.getRooms().length,
  timestamp: Date.now(),
}))

export const listRooms = pub.query(() => {
  return store.getRooms().map((room) => ({
    ...room,
    online: store.getOnlineUsers(room.id).length,
  }))
})

// Authenticated queries
export const getRoom = authed.query(
  { parse: (data: unknown) => data as { roomId: string } },
  ({ input, username }) => {
    const room = store.getRoom(input.roomId)
    if (!room) throw new Error('Room not found')
    return {
      ...room,
      online: store.getOnlineUsers(room.id),
      messages: store.getMessages(room.id, 50),
    }
  }
)

export const getMessages = authed.query(
  { parse: (data: unknown) => data as { roomId: string; limit?: number } },
  ({ input }) => {
    return store.getMessages(input.roomId, input.limit || 50)
  }
)

// Authenticated + rate-limited mutations
const authedWithLimit = authed.use(messageRateLimit)

export const sendMessage = authedWithLimit.mutation(
  { parse: (data: unknown) => data as { roomId: string; content: string } },
  ({ input, username }) => {
    const { roomId, content } = input

    if (!content.trim()) {
      return { ok: false, error: { code: 'INVALID_INPUT', message: 'Message cannot be empty' } }
    }

    if (content.length > 1000) {
      return { ok: false, error: { code: 'INVALID_INPUT', message: 'Message too long (max 1000 chars)' } }
    }

    // Username comes from the auth middleware, not from client input
    const message = store.addMessage(roomId, username, content.trim())
    store.clearTyping(roomId, username)

    emit({
      type: 'message',
      roomId,
      message: {
        id: message.id,
        username: message.username,
        content: message.content,
        timestamp: message.timestamp,
      },
    })

    emit({ type: 'stop_typing', roomId, username })

    return message
  }
)

export const joinRoom = authed.mutation(
  { parse: (data: unknown) => data as { roomId: string } },
  ({ input, username }) => {
    if (!store.getRoom(input.roomId)) {
      return { ok: false, error: { code: 'NOT_FOUND', message: 'Room not found' } }
    }

    store.joinRoom(input.roomId, username)
    emit({ type: 'user_joined', roomId: input.roomId, username })
    return { online: store.getOnlineUsers(input.roomId) }
  }
)

export const leaveRoom = authed.mutation(
  { parse: (data: unknown) => data as { roomId: string } },
  ({ input, username }) => {
    store.leaveRoom(input.roomId, username)
    store.clearTyping(input.roomId, username)
    emit({ type: 'user_left', roomId: input.roomId, username })
    emit({ type: 'stop_typing', roomId: input.roomId, username })
    return { success: true }
  }
)

// Typing with separate rate limit
const authedWithTypingLimit = authed.use(typingRateLimit)

export const setTyping = authedWithTypingLimit.mutation(
  { parse: (data: unknown) => data as { roomId: string } },
  ({ input, username }) => {
    store.setTyping(input.roomId, username)
    emit({ type: 'typing', roomId: input.roomId, username })
    return { success: true }
  }
)

export const stopTyping = authed.mutation(
  { parse: (data: unknown) => data as { roomId: string } },
  ({ input, username }) => {
    store.clearTyping(input.roomId, username)
    emit({ type: 'stop_typing', roomId: input.roomId, username })
    return { success: true }
  }
)

// Authenticated subscription
export const roomEvents = authed.subscription(
  { parse: (data: unknown) => data as { roomId: string } },
  async function* ({ input }): AsyncGenerator<ChatEvent> {
    yield* subscribeToRoom(input.roomId)
  }
)

Notice the key changes:

  • sendMessage no longer accepts username from client input — it uses ctx.username from the auth middleware
  • joinRoom and leaveRoom also use the server-side username
  • sendMessage has message rate limiting (30/min)
  • setTyping has typing rate limiting (10/10s)
  • Queries that access room data require authentication

Update the Client Calls ​

Since username is no longer in the mutation input, update the ChatRoom island:

tsx
// In app/components/ChatRoom.tsx, update the mutation calls:

// Before: rpc.messages.send.mutate({ roomId, username, content })
// After:
await sendMutation.mutateAsync({ roomId, content })

// Before: rpc.rooms.join.mutate({ roomId, username })
// After:
joinMutation.mutate({ roomId })

// Before: rpc.rooms.leave.mutate({ roomId, username })
// After:
leaveMutation.mutate({ roomId })

// Before: rpc.typing.start.mutate({ roomId, username })
// After:
typingMutation.mutate({ roomId })

// Before: rpc.typing.stop.mutate({ roomId, username })
// After:
stopTypingMutation.mutate({ roomId })

The server now trusts the cookie, not the client. If someone tries to send a message with a forged username via curl, the auth middleware will use the cookie value instead.

Error Handling ​

Add error handling to the ChatRoom island for rate limit errors and disconnects:

tsx
// Add to ChatRoom.tsx — inside the component, after the state declarations:

const [error, setError] = useState<string | null>(null)

// Update handleSend:
async function handleSend(e: React.FormEvent) {
  e.preventDefault()
  const content = inputValue.trim()
  if (!content) return

  setInputValue('')
  setError(null)
  clearTimeout(typingTimeoutRef.current)

  try {
    await sendMutation.mutateAsync({ roomId, content })
  } catch (err: any) {
    // Show rate limit errors to the user
    setError(err.message || 'Failed to send message')
    // Restore the input so they can retry
    setInputValue(content)
  }
}

// Add error display above the input:
{error && (
  <div className="px-4 py-2 bg-red-900/30 border-t border-red-800">
    <p className="text-sm text-red-400">{error}</p>
  </div>
)}

The Middleware Chain ​

Here's how a sendMessage call flows through the middleware stack:

Client: rpc.messages.send.mutate({ roomId: 'general', content: 'Hello' })
  │
  ▼
HTTP POST /api/rpc
  │
  ▼
1. authed middleware
   ├── Read Cookie header → extract username
   ├── No username? → return { ok: false, error: 'UNAUTHORIZED' }
   └── Has username → next({ ...ctx, username: 'alice' })
       │
       ▼
2. messageRateLimit middleware
   ├── Check: has 'alice' sent < 30 messages in last 60s?
   ├── Over limit? → return { ok: false, error: 'RATE_LIMITED' }
   └── Under limit → next(ctx)
       │
       ▼
3. sendMessage handler
   ├── Validate content (not empty, not too long)
   ├── store.addMessage(roomId, ctx.username, content)
   ├── emit({ type: 'message', ... })
   └── return message

Middleware composes from left to right. Each use() call wraps the previous chain.

Build and Deploy ​

Build the App ​

bash
bun run build

Deploy with Docker ​

dockerfile
FROM oven/bun:1 AS base
WORKDIR /app

COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production

COPY . .
RUN bun run build

EXPOSE 3000
CMD ["bun", "run", "start"]
bash
docker build -t chatterbox .
docker run -p 3000:3000 chatterbox

Deploy to Fly.io ​

bash
fly launch --name chatterbox
fly deploy

Note on scaling: This tutorial uses an in-memory store, so messages are lost on restart and each server instance has its own message history. For a production chat app, you'd use Redis or a database for persistence and Redis pub/sub for cross-instance event broadcasting. The RPC procedures and subscription patterns remain the same — only the store implementation changes.

What We've Built ​

Across these 5 chapters, you built a real-time chat app that demonstrates the full @ereo/rpc toolkit:

FeatureRPC Concept
Fetch rooms and messagesquery procedures
Send messages, join/leave roomsmutation procedures
Live message streamingsubscription (WebSocket)
Auth from cookiesProcedure middleware (.use())
Spam preventionRate limiting middleware
Type-safe clientcreateClient<AppRouter>() inference
React integrationuseQuery, useMutation, useSubscription
ReconnectionClient reconnect config

Key Takeaways ​

  1. Procedures are composable: Stack middleware with .use() to build auth → rate limit → handler chains
  2. Subscriptions are async generators: yield values to stream them to clients; return() handles cleanup
  3. One WebSocket, many subscriptions: The client multiplexes subscriptions over a single connection
  4. Type inference is automatic: Define the router type once, the client infers everything
  5. Optimistic UI + subscriptions: Mutations update local state immediately; subscriptions sync with other clients

Further Reading ​

← Previous: Client UI

Released under the MIT License.