Arcwayv0.3.0
Cookbook

Real-Time & Events

WebSocket routes, server-initiated push, and event-driven side effects

Patterns for WebSocket real-time updates and event-driven side effects.

WebSocket Route

Add a ws() function to any GET route to make it real-time. Clients using useApi() automatically subscribe:

// api/notifications.js
export const GET = {
  handler: async (ctx) => {
    const { db } = ctx;
    const notifications = await db('notifications')
      .where('user_id', ctx.req.session.userId)
      .orderBy('created_at', 'desc')
      .limit(20);
    return { data: notifications };
  },

  // Presence of ws() enables WebSocket subscriptions on this route
  async ws(ctx) {
    // Optional: auth checks, room setup, etc.
    // Return a cleanup function called on disconnect
  },
};

Mutations (POST, PUT, DELETE) automatically broadcast their response to all subscribers on the same path — no extra code needed.

Client: Subscribe to Updates

// pages/dashboard.jsx
import { useApi } from 'arcway/lib/client';

export default function Dashboard() {
  // Automatically subscribes via WebSocket when endpoint has ws()
  const { data: notifications, loading } = useApi('/notifications');

  if (loading) return <p>Loading...</p>;

  return (
    <ul>
      {notifications.map((n) => (
        <li key={n.id}>{n.message}</li>
      ))}
    </ul>
  );
}

Server-Initiated Push

Push updates to clients from jobs, listeners, or other server-side code:

// listeners/notifications/created.js
import { wsBroadcastToPath } from 'arcway';

export default async (ctx) => {
  const { db } = ctx;
  const notification = ctx.event.payload;

  // Broadcast to all clients subscribed to this user's notifications
  wsBroadcastToPath(`/api/notifications?userId=${notification.user_id}`, {
    data: notification,
  });
};

Event-Driven Side Effects

Keep route handlers focused on the primary action and use event listeners for side effects:

// api/orders/index.js
export const POST = {
  handler: async (ctx) => {
    const { db, events } = ctx;
    const order = await db.transaction(async (trx) => {
      const [id] = await trx('orders').insert({
        user_id: ctx.req.session.userId,
        ...ctx.req.body,
      });
      return trx('orders').where('id', id).first();
    });

    await events.emit('orders/created', order);
    return { status: 201, data: order };
  },
};

Multiple listeners can handle the same event using array exports — all run concurrently:

// listeners/orders/created.js
const sendConfirmationEmail = async (ctx) => {
  const { db, mail } = ctx;
  const user = await db('users').where('id', ctx.event.payload.user_id).first();
  await mail.send({
    to: user.email,
    subject: `Order #${ctx.event.payload.id} confirmed`,
    html: `<p>Thank you for your order!</p>`,
  });
};

const trackAnalytics = async (ctx) => {
  const { db } = ctx;
  await db('analytics').insert({
    event: 'order_created',
    data: JSON.stringify(ctx.event.payload),
  });
};

export default [sendConfirmationEmail, trackAnalytics];

Fan-Out Notifications

Emit an event and let listeners handle delivery to multiple channels:

// api/posts/index.js — POST creates a post and fires an event
export const POST = {
  handler: async (ctx) => {
    const { db, events } = ctx;
    const [id] = await db('posts').insert({
      ...ctx.req.body,
      author_id: ctx.req.session.userId,
    });
    const post = await db('posts').where('id', id).first();
    await events.emit('posts/published', post);
    return { status: 201, data: post };
  },
};
// listeners/posts/published.js — fan out to followers
export default async (ctx) => {
  const { db, queue } = ctx;
  const post = ctx.event.payload;

  // Find all followers of the author
  const followers = await db('follows').where('following_id', post.author_id).pluck('follower_id');
  if (followers.length === 0) return;

  // Batch-queue a notification for each follower
  await queue.pushBulk(
    'send-notification',
    followers.map((userId) => ({
      userId,
      type: 'new_post',
      postId: post.id,
    })),
  );
};

On this page