Third-Party Chat App Example
Third-Party Chat App Example Build a small chat app on top of Enfyra from an external SSR app. Read SSR Frameworks first. This example assumes your app already proxies: /enfyra/** -> Enfyra app /api/** /socket.io/** -> Enfyra app /ws/socket.io/** What You Build Conversation list
Third-Party Chat App Example
Build a small chat app on top of Enfyra from an external SSR app.
Read SSR Frameworks first. This example assumes your app already proxies:
/enfyra/** -> Enfyra app /api/**
/socket.io/** -> Enfyra app /ws/socket.io/**
What You Build
Conversation list
-> REST read
Selected conversation
-> REST message history
Send message
-> Socket.IO event
-> Enfyra event script persists message
-> Enfyra broadcasts to room
1. Create Tables
chat_conversation
| Field | Type |
|---|---|
| title | string |
| kind | string |
| description | text |
| updatedAt | datetime |
| createdBy | many-to-one to user_definition |
| lastMessage | many-to-one to chat_message, nullable |
chat_conversation_member
| Field | Type |
|---|---|
| conversation | many-to-one to chat_conversation |
| member | many-to-one to user_definition |
| role | string |
| joinedAt | datetime |
chat_message
| Field | Type |
|---|---|
| conversation | many-to-one to chat_conversation |
| sender | many-to-one to user_definition |
| text | text |
| persistStatus | string |
chat_message_read
| Field | Type |
|---|---|
| message | many-to-one to chat_message |
| conversation | many-to-one to chat_conversation |
| member | many-to-one to user_definition |
| isRead | boolean |
| readAt | datetime |
Use cascade delete from chat_conversation to members/messages if deleting a conversation should remove its chat data.
2. Load The Conversation List
Only load the list on initial page render.
const conversations = await fetch(
"/enfyra/chat_conversation?fields=id,title,kind,lastMessage.id,lastMessage.text,lastMessage.createdAt&limit=0",
{ credentials: "include" },
).then((res) => res.json())
Do not load messages for every conversation during page refresh. Load messages after the user selects one conversation.
Sort the list in the frontend by conversation.lastMessage?.createdAt. The conversation keeps only a relation to the latest message, not duplicated preview text/date fields.
3. Load Messages After Selection
const filter = encodeURIComponent(JSON.stringify({
conversation: { id: { _eq: conversationId } },
}))
const messages = await fetch(
`/enfyra/chat_message?filter=${filter}&fields=id,text,createdAt,sender&deep=${encodeURIComponent(JSON.stringify({
sender: {},
}))}&sort=-createdAt,-id&limit=20`,
{ credentials: "include" },
).then((res) => res.json())
Use limit=20 for the visible page. Load older messages only when the user asks.
4. Connect Socket.IO
import { io } from "socket.io-client"
const socket = io("/chat", {
path: "/socket.io",
withCredentials: true,
reconnection: false,
transports: ["polling"],
upgrade: false,
})
socket.emit("chat:join")
socket.on("chat:message", (payload) => {
appendMessage(payload.message)
})
/chat is the Enfyra websocket namespace. /socket.io is the transport path proxied by the SSR app.
5. Add A Message Event
Create a websocket event named chat:message.
Event script:
const { conversationId, messageId, text } = @BODY
if (!conversationId) @THROW400("conversationId is required")
if (!text) @THROW400("text is required")
const membership = await @REPOS.chat_conversation_member.find({
filter: {
conversation: { id: { _eq: conversationId } },
member: { id: { _eq: @USER.id } }
},
fields: "id",
limit: 1
})
if (!membership.data[0]) @THROW403("Not a conversation member")
const created = await @REPOS.chat_message.create({
data: {
conversation: { id: conversationId },
sender: { id: @USER.id },
text,
persistStatus: "persisted"
}
})
await @REPOS.chat_conversation.update({
filter: { id: { _eq: conversationId } },
data: {
lastMessage: { id: created.data[0].id },
updatedAt: new Date().toISOString()
}
})
@SOCKET.emitToRoom(`conversation:${conversationId}`, "chat:message", {
message: created.data[0]
})
@SOCKET.reply("chat:message:sent", {
messageId,
message: created.data[0]
})
Client send:
socket.emit("chat:message", {
conversationId,
messageId: crypto.randomUUID(),
text,
})
6. Add A Join Event
Create a websocket event named chat:join.
const memberships = await @REPOS.chat_conversation_member.find({
filter: {
member: { id: { _eq: @USER.id } }
},
fields: "id,conversation",
deep: { conversation: { fields: "id" } },
limit: 0
})
for (const row of memberships.data || []) {
const conversationId = row.conversation?.id
if (conversationId) {
@SOCKET.join(`conversation:${conversationId}`)
}
}
@SOCKET.reply("chat:joined", {
joined: memberships.data?.length || 0
})
7. Keep lastMessage Correct On Delete
If users can delete individual messages, add a DELETE /chat_message pre-hook and post-hook.
The pre-hook snapshots the deleted message before the default delete handler runs:
const message = await @REPOS.chat_message.find({
filter: { id: { _eq: @PARAMS.id } },
fields: "id,createdAt,conversation",
limit: 1
})
const row = message.data?.[0]
if (!row) return
const conversationId = row.conversation?.id || row.conversation
const conversation = await @REPOS.chat_conversation.find({
filter: { id: { _eq: conversationId } },
fields: "id,lastMessage",
limit: 1
})
const current = conversation.data?.[0]
const currentLastId = current?.lastMessage?.id || current?.lastMessage
const membership = await @REPOS.chat_conversation_member.find({
filter: {
conversation: { id: { _eq: conversationId } },
member: { id: { _eq: @USER.id } }
},
fields: "id",
limit: 1
})
if (!membership.data?.length) @THROW403("Not a conversation member")
@SHARE.deletedChatMessage = {
id: row.id,
conversationId,
wasLastMessage: String(currentLastId || "") === String(row.id)
}
The post-hook only repairs the conversation when the deleted message was the current lastMessage:
const deleted = @SHARE.deletedChatMessage
if (!deleted?.conversationId) return
if (!deleted.wasLastMessage) return
const nextLast = await @REPOS.chat_message.find({
filter: { conversation: { id: { _eq: deleted.conversationId } } },
fields: "id",
sort: "-createdAt,-id",
limit: 1
})
await @REPOS.chat_conversation.update({
id: deleted.conversationId,
data: {
lastMessage: nextLast.data?.[0]?.id ? { id: nextLast.data[0].id } : null
}
})
8. Add Read State
When a user opens a conversation, emit chat:read.
socket.emit("chat:read", {
conversationId,
readAt: new Date().toISOString(),
})
The server marks chat_message_read rows as read for @USER and emits chat:read to user rooms so other open tabs clear unread dots.
Common Mistakes
Loading all messages on refresh
Load conversations first. Load messages only after selection.
Sending messages through REST only
REST can create records, but chat UX needs realtime delivery. Use Socket.IO for send and broadcast.
Trusting conversationId without membership check
Every room join and send event should verify membership before doing work.