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,
})),
);
};