64 KiB
Self-Hosted Chat Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace Stream Chat with a self-hosted chat system using Cloudflare Durable Objects for real-time WebSocket messaging, Supabase Postgres for message persistence, and chatscope for the React UI.
Architecture: A new Cloudflare Worker (apps/chat-worker) handles WebSocket connections via Durable Objects (one per tablo channel) and REST endpoints for message history/unread counts. Messages persist to Supabase Postgres via PostgREST. The frontend replaces stream-chat-react with @chatscope/chat-ui-kit-react components wired to a custom useChat hook.
Tech Stack: Cloudflare Workers + Durable Objects, Supabase Postgres (PostgREST), @chatscope/chat-ui-kit-react, TypeScript, Hono (Worker routing)
Spec: docs/superpowers/specs/2026-04-11-self-hosted-chat-design.md
File Structure
New files
apps/chat-worker/
package.json # Worker package with wrangler, hono, jose deps
wrangler.toml # DO bindings, env vars (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET)
tsconfig.json # TypeScript config for Workers runtime
src/
index.ts # Hono Worker entry: JWT auth, routing, DO dispatch
durable-objects/
ChatRoom.ts # Hibernatable WebSocket DO — broadcast, typing, presence
lib/
supabase.ts # PostgREST helper (fetch-based, no SDK)
auth.ts # JWT verification using jose
types.ts # WebSocket message types, API response types
apps/main/src/
hooks/
useChat.ts # WebSocket connection, message send/receive, reconnect, typing
useChatUnread.ts # Polls /chat/unread endpoint
pages/
chat.tsx # REWRITE: replace Stream components with chatscope
components/
ChatChannelPreview.tsx # chatscope Conversation wrapper (replaces ChannelPreview.tsx)
ChatHeader.tsx # Channel header (replaces CustomChannelHeader.tsx)
Modified files
apps/main/src/lib/routes.tsx # Remove ChatProvider wrapper from chat route
apps/main/src/providers/UserStoreProvider.tsx # Remove streamToken from User type
apps/main/package.json # Remove stream-chat, stream-chat-react; add @chatscope/chat-ui-kit-react
apps/main/.env.local # Remove VITE_STREAM_CHAT_API_KEY, add VITE_CHAT_WS_URL
apps/main/.env.staging # Same
apps/main/.env.production # Same
apps/api/src/config.ts # Remove STREAM_CHAT_API_KEY, STREAM_CHAT_API_SECRET
apps/api/src/secrets.ts # Remove streamChatApiSecret, streamChatApiSecretStaging
apps/api/src/types/app.types.ts # Remove streamServerClient from BaseEnv
apps/api/src/middlewares/middleware.ts # Remove streamChatMiddleware
apps/api/src/routers/index.ts # Remove streamChat middleware usage
apps/api/src/routers/user.ts # Remove signUpToStream, streamToken from getMe
apps/api/src/routers/tablo.ts # Remove all Stream channel operations
apps/api/src/helpers/helpers.ts # Remove streamServerClient from createInvitedUser
apps/api/package.json # Remove stream-chat dependency
Deleted files
apps/main/src/providers/ChatProvider.tsx # Stream Chat provider — replaced by useChat
apps/main/src/components/ChannelPreview.tsx # Stream-specific — replaced by ChatChannelPreview
apps/main/src/components/CustomChannelHeader.tsx # Stream-specific — replaced by ChatHeader
apps/main/src/hooks/channel.ts # useChannelFromUrl, useTabloDiscussionUnread — replaced
Kept as-is
apps/main/src/components/ChannelBadge.tsx # Generic component, reused in new chat UI
Task 1: Database Migration — Create messages and channel_read_state tables
Files:
- Create:
supabase/migrations/20260411_create_chat_tables.sql
This task creates the Postgres tables that the chat system writes to and reads from. Everything else depends on these existing.
- Step 1: Write the migration SQL
-- supabase/migrations/20260411_create_chat_tables.sql
-- Messages table
CREATE TABLE IF NOT EXISTS messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id),
text text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
-- Read state table
CREATE TABLE IF NOT EXISTS channel_read_state (
user_id uuid NOT NULL REFERENCES auth.users(id),
channel_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, channel_id)
);
-- RLS policies
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE channel_read_state ENABLE ROW LEVEL SECURITY;
-- Messages: users can read messages in channels they are members of
CREATE POLICY "Users can read messages in their tablos"
ON messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM tablo_access
WHERE tablo_access.tablo_id = messages.channel_id
AND tablo_access.user_id = auth.uid()
AND tablo_access.is_active = true
)
);
-- Messages: service role inserts (from chat worker) bypass RLS
-- No INSERT policy needed — the chat worker uses the service role key
-- Read state: users can read/write their own read state
CREATE POLICY "Users can manage their own read state"
ON channel_read_state FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
- Step 2: Apply the migration
Run: npx supabase db push (or apply via Supabase dashboard if using hosted migrations)
Expected: Tables messages and channel_read_state created with indexes and RLS policies.
- Step 3: Commit
git add supabase/migrations/20260411_create_chat_tables.sql
git commit -m "feat(chat): add messages and channel_read_state tables"
Task 2: Chat Worker — Project scaffold and configuration
Files:
- Create:
apps/chat-worker/package.json - Create:
apps/chat-worker/tsconfig.json - Create:
apps/chat-worker/wrangler.toml - Create:
apps/chat-worker/src/lib/types.ts
This task sets up the new Cloudflare Worker project in the monorepo with proper configuration.
- Step 1: Create package.json
{
"name": "@xtablo/chat-worker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"deploy:staging": "wrangler deploy --env staging",
"deploy:prod": "wrangler deploy --env production",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.7.7",
"jose": "^6.0.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"typescript": "^5.8.3",
"wrangler": "^4.14.0"
}
}
- Step 2: Create tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
- Step 3: Create wrangler.toml
name = "xtablo-chat"
main = "src/index.ts"
compatibility_date = "2025-07-09"
[durable_objects]
bindings = [
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
]
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
[observability]
enabled = true
[vars]
SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co"
# Secrets (set via `wrangler secret put`):
# SUPABASE_SERVICE_ROLE_KEY
# JWT_SECRET
[env.staging]
route = { pattern = "chat-staging.xtablo.com", custom_domain = true }
[env.production]
route = { pattern = "chat.xtablo.com", custom_domain = true }
- Step 4: Create shared types
Create apps/chat-worker/src/lib/types.ts:
// WebSocket message types — client to server
export type ClientMessage =
| { type: "message.send"; text: string; clientId: string }
| { type: "typing.start" }
| { type: "typing.stop" }
| { type: "presence.ping" };
// WebSocket message types — server to client
export type ServerMessage =
| { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string }
| { type: "typing"; userId: string; isTyping: boolean }
| { type: "presence.update"; userId: string; status: "online" | "offline" }
| { type: "error"; code: string; message: string };
// REST API types
export interface ChatMessage {
id: string;
channel_id: string;
user_id: string;
text: string;
created_at: string;
updated_at: string | null;
deleted_at: string | null;
}
export interface UnreadCount {
channel_id: string;
unread_count: number;
}
// Worker environment bindings
export interface Env {
CHAT_ROOM: DurableObjectNamespace;
SUPABASE_URL: string;
SUPABASE_SERVICE_ROLE_KEY: string;
JWT_SECRET: string;
}
- Step 5: Install dependencies
Run: cd apps/chat-worker && pnpm install
- Step 6: Commit
git add apps/chat-worker/
git commit -m "feat(chat-worker): scaffold Cloudflare Worker project"
Task 3: Chat Worker — JWT auth and Supabase PostgREST helper
Files:
-
Create:
apps/chat-worker/src/lib/auth.ts -
Create:
apps/chat-worker/src/lib/supabase.ts -
Step 1: Create JWT auth helper
Create apps/chat-worker/src/lib/auth.ts:
import { jwtVerify, createRemoteJWKSet } from "jose";
interface AuthResult {
userId: string;
email: string | null;
}
/**
* Verify a Supabase JWT and extract the user ID.
* Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim.
*/
export async function verifyJwt(token: string, jwtSecret: string): Promise<AuthResult> {
const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, secret, {
issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1",
});
if (!payload.sub) {
throw new Error("Missing sub claim in JWT");
}
return {
userId: payload.sub,
email: (payload.email as string) ?? null,
};
}
/**
* Extract Bearer token from Authorization header.
*/
export function extractToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
return authHeader.slice(7);
}
- Step 2: Create Supabase PostgREST helper
Create apps/chat-worker/src/lib/supabase.ts:
/**
* Thin PostgREST client using fetch — no Supabase SDK dependency.
* Used by both the Worker (history queries) and the Durable Object (message persistence).
*/
export class PostgREST {
private baseUrl: string;
private serviceRoleKey: string;
constructor(supabaseUrl: string, serviceRoleKey: string) {
this.baseUrl = `${supabaseUrl}/rest/v1`;
this.serviceRoleKey = serviceRoleKey;
}
private headers(): Record<string, string> {
return {
"apikey": this.serviceRoleKey,
"Authorization": `Bearer ${this.serviceRoleKey}`,
"Content-Type": "application/json",
"Prefer": "return=representation",
};
}
/** Insert a row and return the inserted data. */
async insert<T>(table: string, data: Record<string, unknown>): Promise<T[]> {
const res = await fetch(`${this.baseUrl}/${table}`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST insert failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Upsert a row (requires Prefer: resolution=merge-duplicates). */
async upsert<T>(table: string, data: Record<string, unknown>, onConflict: string): Promise<T[]> {
const headers = this.headers();
headers["Prefer"] = "return=representation,resolution=merge-duplicates";
const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, {
method: "POST",
headers,
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST upsert failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Select rows with PostgREST query string. */
async select<T>(table: string, query: string): Promise<T[]> {
const res = await fetch(`${this.baseUrl}/${table}?${query}`, {
method: "GET",
headers: this.headers(),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST select failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Select with exact count header for unread queries. */
async count(table: string, query: string): Promise<number> {
const headers = this.headers();
headers["Prefer"] = "count=exact";
headers["Range-Unit"] = "items";
headers["Range"] = "0-0";
const res = await fetch(`${this.baseUrl}/${table}?${query}`, {
method: "HEAD",
headers,
});
const contentRange = res.headers.get("Content-Range");
if (!contentRange) return 0;
// Content-Range format: "0-0/42" or "*/0"
const total = contentRange.split("/")[1];
return total === "*" ? 0 : parseInt(total, 10);
}
}
- Step 3: Commit
git add apps/chat-worker/src/lib/
git commit -m "feat(chat-worker): add JWT auth and PostgREST helpers"
Task 4: Chat Worker — ChatRoom Durable Object
Files:
- Create:
apps/chat-worker/src/durable-objects/ChatRoom.ts
This is the core real-time component. One instance per tablo channel, managing WebSocket connections, broadcasting messages, and persisting to Postgres.
- Step 1: Create the ChatRoom Durable Object
Create apps/chat-worker/src/durable-objects/ChatRoom.ts:
import { DurableObject } from "cloudflare:workers";
import type { Env, ClientMessage, ServerMessage } from "../lib/types";
import { PostgREST } from "../lib/supabase";
export class ChatRoom extends DurableObject<Env> {
private postgrest: PostgREST | null = null;
private getPostgREST(): PostgREST {
if (!this.postgrest) {
this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY);
}
return this.postgrest;
}
/**
* Called by the Worker to initiate a WebSocket connection.
* The userId has already been authenticated by the Worker.
*/
async handleWebSocket(request: Request, userId: string): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
// Accept with userId as a tag for filtering later
this.ctx.acceptWebSocket(server, [userId]);
// Broadcast presence to existing connections
this.broadcast({
type: "presence.update",
userId,
status: "online",
}, server);
return new Response(null, { status: 101, webSocket: client });
}
/**
* Hibernatable WebSocket handler — called when a message arrives.
*/
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
const tags = this.ctx.getTags(ws);
const userId = tags[0];
if (!userId) {
ws.close(4001, "Missing user identity");
return;
}
let msg: ClientMessage;
try {
msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
} catch {
this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" });
return;
}
switch (msg.type) {
case "message.send":
await this.handleSendMessage(ws, userId, msg.text, msg.clientId);
break;
case "typing.start":
this.broadcast({ type: "typing", userId, isTyping: true }, ws);
break;
case "typing.stop":
this.broadcast({ type: "typing", userId, isTyping: false }, ws);
break;
case "presence.ping":
// No-op — the connection itself is the presence signal
break;
}
}
/**
* Hibernatable WebSocket handler — called when a connection closes.
*/
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const tags = this.ctx.getTags(ws);
const userId = tags[0];
if (userId) {
// Only broadcast offline if no other connections for this user
const remaining = this.ctx.getWebSockets(userId);
if (remaining.length === 0) {
this.broadcast({ type: "presence.update", userId, status: "offline" });
}
}
}
/**
* Hibernatable WebSocket handler — called on error.
*/
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error("WebSocket error:", error);
ws.close(1011, "Internal error");
}
private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise<void> {
if (!text || text.trim().length === 0) {
this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" });
return;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
// Extract channelId from the DO's own ID name
// The Worker creates the DO with id = channelId, so we read it from ctx.id
const channelId = this.getChannelId();
const serverMsg: ServerMessage = {
type: "message.new",
id,
userId,
text: text.trim(),
createdAt,
clientId,
};
// Broadcast to all connections (including sender, for server echo)
this.broadcast(serverMsg);
// Persist to Postgres asynchronously (fire-and-forget with retry)
this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt));
}
private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise<void> {
const db = this.getPostgREST();
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await db.insert("messages", {
id,
channel_id: channelId,
user_id: userId,
text,
created_at: createdAt,
});
return;
} catch (error) {
console.error(`Message persist attempt ${attempt + 1} failed:`, error);
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
}
}
}
console.error(`Failed to persist message ${id} after ${maxRetries} attempts`);
}
/**
* Get the channel ID from the Durable Object's name.
* The Worker creates the DO ID using `env.CHAT_ROOM.idFromName(channelId)`.
*/
private getChannelId(): string {
// The DO name is set by the Worker when creating the stub.
// We store it on first WebSocket connect via the request URL.
// Fallback: use the hex ID (not ideal but safe).
return this.ctx.id.toString();
}
/** Send a typed message to a single WebSocket. */
private sendTo(ws: WebSocket, msg: ServerMessage): void {
try {
ws.send(JSON.stringify(msg));
} catch {
// Connection already closed
}
}
/** Broadcast a typed message to all connected WebSockets, optionally excluding one. */
private broadcast(msg: ServerMessage, exclude?: WebSocket): void {
const payload = JSON.stringify(msg);
for (const ws of this.ctx.getWebSockets()) {
if (ws !== exclude) {
try {
ws.send(payload);
} catch {
// Connection already closed, skip
}
}
}
}
}
Note on getChannelId(): The DO's own hex ID isn't the channel UUID. We need the channel ID for Postgres writes. We'll pass it via the WebSocket URL path and store it. Let me fix this:
Actually, the cleanest approach: the Worker passes the channelId as a query param in the internal DO request URL. The DO reads it on first WebSocket accept and stores it in transactional storage. Let me update the implementation:
Replace the handleWebSocket and getChannelId methods:
async handleWebSocket(request: Request, userId: string, channelId: string): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
// Store channelId if not already stored
const stored = await this.ctx.storage.get<string>("channelId");
if (!stored) {
await this.ctx.storage.put("channelId", channelId);
}
this.ctx.acceptWebSocket(server, [userId]);
this.broadcast({
type: "presence.update",
userId,
status: "online",
}, server);
return new Response(null, { status: 101, webSocket: client });
}
private async getChannelId(): Promise<string> {
const channelId = await this.ctx.storage.get<string>("channelId");
if (!channelId) throw new Error("channelId not stored in DO");
return channelId;
}
And update handleSendMessage to await this.getChannelId().
The full file should incorporate these changes. Here is the complete ChatRoom.ts:
import { DurableObject } from "cloudflare:workers";
import type { Env, ClientMessage, ServerMessage } from "../lib/types";
import { PostgREST } from "../lib/supabase";
export class ChatRoom extends DurableObject<Env> {
private postgrest: PostgREST | null = null;
private getPostgREST(): PostgREST {
if (!this.postgrest) {
this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY);
}
return this.postgrest;
}
async handleWebSocket(request: Request, userId: string, channelId: string): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
const stored = await this.ctx.storage.get<string>("channelId");
if (!stored) {
await this.ctx.storage.put("channelId", channelId);
}
this.ctx.acceptWebSocket(server, [userId]);
this.broadcast({
type: "presence.update",
userId,
status: "online",
}, server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
const tags = this.ctx.getTags(ws);
const userId = tags[0];
if (!userId) {
ws.close(4001, "Missing user identity");
return;
}
let msg: ClientMessage;
try {
msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
} catch {
this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" });
return;
}
switch (msg.type) {
case "message.send":
await this.handleSendMessage(ws, userId, msg.text, msg.clientId);
break;
case "typing.start":
this.broadcast({ type: "typing", userId, isTyping: true }, ws);
break;
case "typing.stop":
this.broadcast({ type: "typing", userId, isTyping: false }, ws);
break;
case "presence.ping":
break;
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const tags = this.ctx.getTags(ws);
const userId = tags[0];
if (userId) {
const remaining = this.ctx.getWebSockets(userId);
if (remaining.length === 0) {
this.broadcast({ type: "presence.update", userId, status: "offline" });
}
}
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error("WebSocket error:", error);
ws.close(1011, "Internal error");
}
private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise<void> {
if (!text || text.trim().length === 0) {
this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" });
return;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
const channelId = await this.getChannelId();
const serverMsg: ServerMessage = {
type: "message.new",
id,
userId,
text: text.trim(),
createdAt,
clientId,
};
this.broadcast(serverMsg);
this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt));
}
private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise<void> {
const db = this.getPostgREST();
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await db.insert("messages", {
id,
channel_id: channelId,
user_id: userId,
text,
created_at: createdAt,
});
return;
} catch (error) {
console.error(`Message persist attempt ${attempt + 1} failed:`, error);
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
}
}
}
console.error(`Failed to persist message ${id} after ${maxRetries} attempts`);
}
private async getChannelId(): Promise<string> {
const channelId = await this.ctx.storage.get<string>("channelId");
if (!channelId) throw new Error("channelId not stored in DO");
return channelId;
}
private sendTo(ws: WebSocket, msg: ServerMessage): void {
try {
ws.send(JSON.stringify(msg));
} catch {
// Connection already closed
}
}
private broadcast(msg: ServerMessage, exclude?: WebSocket): void {
const payload = JSON.stringify(msg);
for (const ws of this.ctx.getWebSockets()) {
if (ws !== exclude) {
try {
ws.send(payload);
} catch {
// Connection already closed
}
}
}
}
}
- Step 2: Commit
git add apps/chat-worker/src/durable-objects/ChatRoom.ts
git commit -m "feat(chat-worker): implement ChatRoom Durable Object with WebSocket hibernation"
Task 5: Chat Worker — Hono entry point with routing
Files:
- Create:
apps/chat-worker/src/index.ts
The Worker entry point: authenticates requests, checks membership, dispatches WebSocket upgrades to DOs, and serves REST endpoints for message history, unread counts, and marking channels as read.
- Step 1: Create the Worker entry point
Create apps/chat-worker/src/index.ts:
import { Hono } from "hono";
import { cors } from "hono/cors";
import { ChatRoom } from "./durable-objects/ChatRoom";
import { extractToken, verifyJwt } from "./lib/auth";
import { PostgREST } from "./lib/supabase";
import type { Env, ChatMessage, UnreadCount } from "./lib/types";
// Re-export DO class for wrangler
export { ChatRoom };
const app = new Hono<{ Bindings: Env }>();
// CORS — allow the main app origins
app.use("*", cors({
origin: [
"http://localhost:5173",
"https://app.xtablo.com",
"https://app-staging.xtablo.com",
],
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "OPTIONS"],
}));
// Auth middleware — extract and verify JWT for all routes
// For WebSocket upgrades, the token comes via query param (?token=...) since browsers
// cannot send custom headers on WebSocket connections.
// For REST requests, the token comes via the Authorization header.
app.use("*", async (c, next) => {
const isWebSocket = c.req.header("Upgrade") === "websocket";
const token = isWebSocket
? new URL(c.req.url).searchParams.get("token")
: extractToken(c.req.header("Authorization"));
if (!token) {
return c.json({ error: "Missing authorization" }, 401);
}
try {
const auth = await verifyJwt(token, c.env.JWT_SECRET);
c.set("userId" as never, auth.userId);
} catch (error) {
return c.json({ error: "Invalid token" }, 401);
}
await next();
});
// Helper: check tablo membership via PostgREST
async function checkMembership(db: PostgREST, channelId: string, userId: string): Promise<boolean> {
const rows = await db.select<{ user_id: string }>(
"tablo_access",
`tablo_id=eq.${channelId}&user_id=eq.${userId}&is_active=eq.true&select=user_id&limit=1`
);
return rows.length > 0;
}
// WebSocket upgrade — route to Durable Object
app.get("/chat/ws/:channelId", async (c) => {
const upgradeHeader = c.req.header("Upgrade");
if (upgradeHeader !== "websocket") {
return c.json({ error: "Expected WebSocket upgrade" }, 426);
}
const channelId = c.req.param("channelId");
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
const isMember = await checkMembership(db, channelId, userId);
if (!isMember) {
return c.json({ error: "Not a member of this channel" }, 403);
}
const id = c.env.CHAT_ROOM.idFromName(channelId);
const stub = c.env.CHAT_ROOM.get(id);
return stub.handleWebSocket(c.req.raw, userId, channelId);
});
// GET message history — paginated
app.get("/chat/channels/:channelId/messages", async (c) => {
const channelId = c.req.param("channelId");
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
const isMember = await checkMembership(db, channelId, userId);
if (!isMember) {
return c.json({ error: "Not a member of this channel" }, 403);
}
const before = c.req.query("before");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 100);
let query = `channel_id=eq.${channelId}&deleted_at=is.null&select=id,channel_id,user_id,text,created_at&order=created_at.desc&limit=${limit}`;
if (before) {
query += `&created_at=lt.${before}`;
}
const messages = await db.select<ChatMessage>(
"messages",
query
);
return c.json({ messages: messages.reverse(), hasMore: messages.length === limit });
});
// POST mark channel as read
app.post("/chat/channels/:channelId/read", async (c) => {
const channelId = c.req.param("channelId");
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
await db.upsert("channel_read_state", {
user_id: userId,
channel_id: channelId,
last_read_at: new Date().toISOString(),
}, "user_id,channel_id");
return c.json({ ok: true });
});
// GET unread counts for current user across all channels
app.get("/chat/unread", async (c) => {
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
// Get all tablos the user has access to
const accessRows = await db.select<{ tablo_id: string }>(
"tablo_access",
`user_id=eq.${userId}&is_active=eq.true&select=tablo_id`
);
if (accessRows.length === 0) {
return c.json({ unread: [] });
}
// For each channel, get unread count
// Use a Postgres function or do it in a loop (at small scale, the loop is fine)
const unread: UnreadCount[] = [];
for (const { tablo_id } of accessRows) {
// Get last read time
const readState = await db.select<{ last_read_at: string }>(
"channel_read_state",
`user_id=eq.${userId}&channel_id=eq.${tablo_id}&select=last_read_at&limit=1`
);
const lastReadAt = readState[0]?.last_read_at ?? "1970-01-01T00:00:00Z";
const count = await db.count(
"messages",
`channel_id=eq.${tablo_id}&deleted_at=is.null&created_at=gt.${lastReadAt}`
);
if (count > 0) {
unread.push({ channel_id: tablo_id, unread_count: count });
}
}
return c.json({ unread });
});
export default app;
- Step 2: Run typecheck
Run: cd apps/chat-worker && pnpm typecheck
Expected: No type errors.
- Step 3: Commit
git add apps/chat-worker/src/index.ts
git commit -m "feat(chat-worker): add Hono entry point with WebSocket routing and REST endpoints"
Task 6: Frontend — useChat hook
Files:
- Create:
apps/main/src/hooks/useChat.ts
The core frontend hook that manages the WebSocket connection, message state, typing indicators, presence, and reconnection logic.
- Step 1: Create the useChat hook
Create apps/main/src/hooks/useChat.ts:
import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
interface ChatMessage {
id: string;
userId: string;
text: string;
createdAt: string;
clientId: string;
/** True while the message is only local (not yet echoed by server). */
optimistic?: boolean;
}
type ServerMessage =
| { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string }
| { type: "typing"; userId: string; isTyping: boolean }
| { type: "presence.update"; userId: string; status: "online" | "offline" }
| { type: "error"; code: string; message: string };
const CHAT_WS_BASE = import.meta.env.VITE_CHAT_WS_URL as string;
const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string;
export function useChat(channelId: string | undefined) {
const { session } = useSession();
const token = session?.access_token;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
const [hasMoreMessages, setHasMoreMessages] = useState(true);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const typingTimerRef = useRef<ReturnType<typeof setTimeout>>();
const isTypingRef = useRef(false);
// Fetch message history from REST endpoint
const fetchHistory = useCallback(async (before?: string) => {
if (!channelId || !token) return;
const params = new URLSearchParams({ limit: "50" });
if (before) params.set("before", before);
const res = await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/messages?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json() as { messages: ChatMessage[]; hasMore: boolean };
setHasMoreMessages(data.hasMore);
if (before) {
// Prepend older messages
setMessages((prev) => [...data.messages, ...prev]);
} else {
// Initial load
setMessages(data.messages);
}
}, [channelId, token]);
// Load more (pagination)
const loadMoreMessages = useCallback(() => {
if (messages.length === 0 || !hasMoreMessages) return;
const oldest = messages[0];
fetchHistory(oldest.createdAt);
}, [messages, hasMoreMessages, fetchHistory]);
// WebSocket connection management
useEffect(() => {
if (!channelId || !token) return;
const connect = () => {
// Token passed via query param because browsers cannot send custom headers on WS connections
const wsUrl = `${CHAT_WS_BASE}/chat/ws/${channelId}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setIsConnected(true);
reconnectAttemptRef.current = 0;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data) as ServerMessage;
switch (msg.type) {
case "message.new":
setMessages((prev) => {
// Deduplicate: replace optimistic message with server version
const withoutOptimistic = prev.filter(
(m) => !(m.clientId === msg.clientId && m.optimistic)
);
// Avoid duplicate if message already received
if (withoutOptimistic.some((m) => m.id === msg.id)) {
return withoutOptimistic;
}
return [...withoutOptimistic, {
id: msg.id,
userId: msg.userId,
text: msg.text,
createdAt: msg.createdAt,
clientId: msg.clientId,
}];
});
break;
case "typing":
setTypingUsers((prev) =>
msg.isTyping
? prev.includes(msg.userId) ? prev : [...prev, msg.userId]
: prev.filter((id) => id !== msg.userId)
);
break;
case "presence.update":
setOnlineUsers((prev) =>
msg.status === "online"
? prev.includes(msg.userId) ? prev : [...prev, msg.userId]
: prev.filter((id) => id !== msg.userId)
);
break;
case "error":
console.error("Chat error:", msg.code, msg.message);
break;
}
};
ws.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Exponential backoff reconnect
const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000);
reconnectAttemptRef.current++;
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
wsRef.current = ws;
};
// Load initial history then connect WebSocket
fetchHistory().then(connect);
return () => {
clearTimeout(reconnectTimerRef.current);
clearTimeout(typingTimerRef.current);
wsRef.current?.close();
wsRef.current = null;
setMessages([]);
setIsConnected(false);
setTypingUsers([]);
setOnlineUsers([]);
};
}, [channelId, token, fetchHistory]);
// Send message
const sendMessage = useCallback((text: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const clientId = crypto.randomUUID();
// Optimistic update
setMessages((prev) => [
...prev,
{
id: `optimistic-${clientId}`,
userId: session?.user?.id ?? "",
text,
createdAt: new Date().toISOString(),
clientId,
optimistic: true,
},
]);
wsRef.current.send(JSON.stringify({ type: "message.send", text, clientId }));
// Stop typing when sending
if (isTypingRef.current) {
wsRef.current.send(JSON.stringify({ type: "typing.stop" }));
isTypingRef.current = false;
clearTimeout(typingTimerRef.current);
}
}, [session?.user?.id]);
// Typing indicator
const sendTyping = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
if (!isTypingRef.current) {
isTypingRef.current = true;
wsRef.current.send(JSON.stringify({ type: "typing.start" }));
}
clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "typing.stop" }));
}
isTypingRef.current = false;
}, 2000);
}, []);
// Mark as read
const markAsRead = useCallback(async () => {
if (!channelId || !token) return;
await fetch(`${CHAT_API_BASE}/chat/channels/${channelId}/read`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
}, [channelId, token]);
return {
messages,
sendMessage,
sendTyping,
isConnected,
typingUsers,
onlineUsers,
loadMoreMessages,
hasMoreMessages,
markAsRead,
};
}
- Step 2: Commit
git add apps/main/src/hooks/useChat.ts
git commit -m "feat(chat): add useChat hook with WebSocket connection and reconnection"
Task 7: Frontend — useChatUnread hook
Files:
- Create:
apps/main/src/hooks/useChatUnread.ts
Polls the chat worker for unread counts across all channels. Replaces useTabloDiscussionUnread.
- Step 1: Create the useChatUnread hook
Create apps/main/src/hooks/useChatUnread.ts:
import { useQuery } from "@tanstack/react-query";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
const CHAT_API_BASE = import.meta.env.VITE_CHAT_API_URL as string;
interface UnreadCount {
channel_id: string;
unread_count: number;
}
export function useChatUnread() {
const { session } = useSession();
const token = session?.access_token;
const { data } = useQuery({
queryKey: ["chat-unread"],
queryFn: async (): Promise<UnreadCount[]> => {
const res = await fetch(`${CHAT_API_BASE}/chat/unread`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const json = await res.json() as { unread: UnreadCount[] };
return json.unread;
},
enabled: !!token,
refetchInterval: 30_000,
refetchOnWindowFocus: true,
});
return {
unreadCounts: data ?? [],
getUnreadCount: (channelId: string) =>
data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0,
hasUnread: (channelId: string) =>
(data?.find((u) => u.channel_id === channelId)?.unread_count ?? 0) > 0,
};
}
- Step 2: Commit
git add apps/main/src/hooks/useChatUnread.ts
git commit -m "feat(chat): add useChatUnread hook for polling unread counts"
Task 8: Frontend — Chat UI components with chatscope
Files:
- Create:
apps/main/src/components/ChatChannelPreview.tsx - Create:
apps/main/src/components/ChatHeader.tsx - Modify:
apps/main/src/pages/chat.tsx - Modify:
apps/main/src/lib/routes.tsx - Modify:
apps/main/package.json
This task rewrites the chat page to use chatscope components instead of stream-chat-react.
- Step 1: Install chatscope
Run: cd apps/main && pnpm add @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles
- Step 2: Create ChatChannelPreview component
Create apps/main/src/components/ChatChannelPreview.tsx:
import { ChannelBadge } from "@ui/components/ChannelBadge";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Badge } from "@xtablo/ui/components/badge";
import { twMerge } from "tailwind-merge";
interface ChatChannelPreviewProps {
tablo: UserTablo;
isActive: boolean;
onClick: () => void;
unreadCount: number;
lastMessage?: string;
lastMessageTime?: string;
isOnline: boolean;
}
function formatTimestamp(timestamp: string | Date): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
}
export function ChatChannelPreview({
tablo,
isActive,
onClick,
unreadCount,
lastMessage,
lastMessageTime,
isOnline,
}: ChatChannelPreviewProps) {
return (
<div
className={twMerge(
"group relative flex items-center gap-3 p-3 cursor-pointer transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800",
isActive && "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800"
)}
onClick={onClick}
>
<ChannelBadge tablo={tablo} displayTitle={tablo.name} isOnline={isOnline} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h3
className={twMerge(
"font-medium text-gray-900 dark:text-gray-100 truncate",
isActive && "text-[#804EEC] dark:text-purple-400"
)}
>
{tablo.name}
</h3>
{lastMessageTime && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2 shrink-0">
{formatTimestamp(lastMessageTime)}
</span>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 max-h-10 overflow-hidden">
{lastMessage ?? "No messages yet"}
</p>
{unreadCount > 0 && (
<div className="ml-2 shrink-0">
<Badge
color="indigo"
className="text-xs min-w-[20px] h-5 px-2 py-0 flex items-center justify-center"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
</div>
)}
</div>
</div>
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#804EEC] dark:bg-purple-400 rounded-r-full" />
)}
</div>
);
}
- Step 3: Create ChatHeader component
Create apps/main/src/components/ChatHeader.tsx:
import { ChannelBadge } from "@ui/components/ChannelBadge";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
interface ChatHeaderProps {
tablo: UserTablo | null;
onToggleChannelList?: () => void;
isChannelListExpanded?: boolean;
onlineUsers: string[];
}
export function ChatHeader({
tablo,
onToggleChannelList,
isChannelListExpanded = false,
onlineUsers,
}: ChatHeaderProps) {
const memberCount = onlineUsers.length;
return (
<div className="flex items-center px-4 py-3 border-b border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-800/40">
{onToggleChannelList && (
<button
onClick={onToggleChannelList}
className="mr-2 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle channel list"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${
isChannelListExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{tablo && (
<>
<ChannelBadge tablo={tablo} displayTitle={tablo.name} isOnline={memberCount > 0} />
<div className="ml-3">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{tablo.name}</h2>
{memberCount > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{memberCount} online
</p>
)}
</div>
</>
)}
</div>
);
}
- Step 4: Rewrite the chat page
Replace the contents of apps/main/src/pages/chat.tsx with:
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
ChatContainer,
MessageList,
Message,
MessageInput,
TypingIndicator,
} from "@chatscope/chat-ui-kit-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChatChannelPreview } from "../components/ChatChannelPreview";
import { ChatHeader } from "../components/ChatHeader";
import { useChat } from "../hooks/useChat";
import { useChatUnread } from "../hooks/useChatUnread";
import { useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
export function ChatPage() {
const user = useUser();
const { channelId } = useParams();
const navigate = useNavigate();
const { data: tablos } = useTablosList();
const { getUnreadCount } = useChatUnread();
const [isChannelListExpanded, setIsChannelListExpanded] = useState(!channelId);
const {
messages,
sendMessage,
sendTyping,
isConnected,
typingUsers,
onlineUsers,
loadMoreMessages,
hasMoreMessages,
markAsRead,
} = useChat(channelId);
const activeTablo = tablos?.find((t) => t.id === channelId) ?? null;
// Mark as read when channel is focused
useEffect(() => {
if (channelId && messages.length > 0) {
markAsRead();
}
}, [channelId, messages.length, markAsRead]);
const handleSend = (innerHtml: string, textContent: string) => {
const text = textContent.trim();
if (!text) return;
sendMessage(text);
};
const handleChannelSelect = (tabloId: string) => {
navigate(`/chat/${tabloId}`);
};
return (
<div className="flex flex-col h-[calc(100vh-75px)] bg-gray-50 dark:bg-background">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 shrink-0">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Channel list sidebar */}
<div
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
isChannelListExpanded ? "w-80" : "w-0"
}`}
>
<div className="overflow-y-auto h-full">
{tablos?.map((tablo) => (
<ChatChannelPreview
key={tablo.id}
tablo={tablo}
isActive={channelId === tablo.id}
onClick={() => handleChannelSelect(tablo.id)}
unreadCount={getUnreadCount(tablo.id)}
isOnline={onlineUsers.some((uid) => uid !== user.id)}
/>
))}
</div>
</div>
{/* Chat area */}
<div className="flex-1 flex flex-col bg-white dark:bg-gray-700/40">
{channelId && activeTablo ? (
<>
<ChatHeader
tablo={activeTablo}
onToggleChannelList={() => setIsChannelListExpanded(!isChannelListExpanded)}
isChannelListExpanded={isChannelListExpanded}
onlineUsers={onlineUsers}
/>
<div className="flex-1 overflow-hidden">
<ChatContainer>
<MessageList
typingIndicator={
typingUsers.length > 0 ? (
<TypingIndicator content="typing..." />
) : undefined
}
>
{messages.map((msg) => (
<Message
key={msg.id}
model={{
message: msg.text,
sentTime: msg.createdAt,
sender: msg.userId,
direction: msg.userId === user.id ? "outgoing" : "incoming",
position: "single",
}}
/>
))}
</MessageList>
<MessageInput
placeholder="Type a message..."
onSend={handleSend}
onChange={() => sendTyping()}
attachButton={false}
/>
</ChatContainer>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
Select a conversation to start chatting
</div>
)}
</div>
</div>
</div>
);
}
- Step 5: Update routes — remove ChatProvider wrapper
In apps/main/src/lib/routes.tsx, change the chat route from:
import ChatProvider from "../providers/ChatProvider";
and
{
path: "chat",
element: (
<ChatProvider>
<ChatPage />
</ChatProvider>
),
children: [{ index: true }, { path: ":channelId" }],
},
to:
{
path: "chat",
element: <ChatPage />,
children: [{ index: true }, { path: ":channelId" }],
},
Remove the import ChatProvider line entirely.
- Step 6: Commit
git add apps/main/src/components/ChatChannelPreview.tsx apps/main/src/components/ChatHeader.tsx apps/main/src/pages/chat.tsx apps/main/src/lib/routes.tsx apps/main/package.json
git commit -m "feat(chat): rewrite chat page with chatscope UI and custom hooks"
Task 9: Frontend — Environment variables
Files:
-
Modify:
apps/main/.env.local -
Modify:
apps/main/.env.staging -
Modify:
apps/main/.env.production -
Step 1: Update .env.local
Remove the line VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx" and add:
VITE_CHAT_WS_URL=ws://localhost:8787
VITE_CHAT_API_URL=http://localhost:8787
- Step 2: Update .env.staging
Remove the line VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" and add:
VITE_CHAT_WS_URL=wss://chat-staging.xtablo.com
VITE_CHAT_API_URL=https://chat-staging.xtablo.com
- Step 3: Update .env.production
Remove the line VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx" and add:
VITE_CHAT_WS_URL=wss://chat.xtablo.com
VITE_CHAT_API_URL=https://chat.xtablo.com
- Step 4: Commit
git add apps/main/.env.local apps/main/.env.staging apps/main/.env.production
git commit -m "feat(chat): update env vars — replace Stream API key with chat worker URLs"
Task 10: Backend — Remove Stream Chat from API
Files:
- Modify:
apps/api/src/types/app.types.ts— removestreamServerClientfrom BaseEnv - Modify:
apps/api/src/middlewares/middleware.ts— remove streamChatMiddleware - Modify:
apps/api/src/routers/index.ts— removestreamChatmiddleware usage - Modify:
apps/api/src/config.ts— remove STREAM_CHAT_API_KEY, STREAM_CHAT_API_SECRET - Modify:
apps/api/src/secrets.ts— remove streamChatApiSecret, streamChatApiSecretStaging - Modify:
apps/api/src/routers/user.ts— remove signUpToStream, streamToken from getMe, Stream from inviteToOrganization and removeOrganizationMember - Modify:
apps/api/src/routers/tablo.ts— remove all Stream channel operations - Modify:
apps/api/src/helpers/helpers.ts— remove streamServerClient from createInvitedUser - Modify:
apps/api/package.json— remove stream-chat dependency - Modify:
apps/api/src/__tests__/routes/tablo.test.ts— remove Stream Chat mocks - Modify:
apps/api/src/__tests__/config/stripe-config.test.ts— remove streamChat mock data
This is the largest task but is entirely removal. Each change is a deletion, not a rewrite.
- Step 1: Remove StreamChat from app.types.ts
In apps/api/src/types/app.types.ts:
Remove the import: import type { StreamChat } from "stream-chat";
Remove streamServerClient: StreamChat; from the BaseEnv.Variables type.
The BaseEnv becomes:
export type BaseEnv = {
Variables: {
supabase: SupabaseClient;
s3_client: S3Client;
transporter: Transporter;
stripe: Stripe;
stripeSync: StripeSync;
};
};
- Step 2: Remove streamChatMiddleware from middleware.ts
In apps/api/src/middlewares/middleware.ts:
- Remove the import:
import { StreamChat } from "stream-chat"; - Remove
streamChatMiddlewarefrom theMiddlewarestype (lines 28-30) - Remove the
streamChatMiddlewarecreation (lines 171-178) - Remove
streamChatMiddlewarefrom the return object (line 258) - Remove the
get streamChat()getter (lines 285-287)
- Step 3: Remove streamChat from router index
In apps/api/src/routers/index.ts, remove line 20:
mainRouter.use(middlewareManager.streamChat);
- Step 4: Remove Stream config vars
In apps/api/src/config.ts:
- Remove lines 11-12 from
AppConfig:STREAM_CHAT_API_KEY: string; STREAM_CHAT_API_SECRET: string; - Remove lines 62-63 (the
getStreamChatApiSecrethelper) - Remove lines 85-89 (the config assignments for
STREAM_CHAT_API_KEYandSTREAM_CHAT_API_SECRET)
- Step 5: Remove Stream from secrets.ts
In apps/api/src/secrets.ts:
- Remove from the
Secretstype:streamChatApiSecret: string;andstreamChatApiSecretStaging: string; - Remove from
loadSecrets(): the lines fetchingstream-chat-api-secret-stagingandstream-chat-api-secret
- Step 6: Remove Stream from user.ts
In apps/api/src/routers/user.ts:
- Remove the
signUpToStreamhandler entirely (lines 14-32) - In
getMehandler (lines 34-71):- Remove
const streamServerClient = c.get("streamServerClient");(line 37) - Remove
const token = streamServerClient.createToken(user_id);(line 64) - Remove
streamToken: tokenfrom the JSON response (line 69) - The response becomes:
return c.json({ ...userData, plan: effectivePlan });
- Remove
- In
inviteToOrganization(lines 514-715):- Remove
const streamServerClient = c.get("streamServerClient");(line 518) - Remove
streamServerClientfromcreateInvitedUsercall (line 614-621) — pass onlysupabase, transporter, ... - Remove the Stream channel addMembers loop (lines 676-683)
- Remove
- In
removeOrganizationMember(lines 717-850):- Remove
const streamServerClient = c.get("streamServerClient");(line 720) - Remove the Stream channel removeMembers loop (lines 829-836)
- Remove
- Remove the route:
userRouter.post("/sign-up-to-stream", ...signUpToStream);(line 855)
- Step 7: Remove Stream from tablo.ts
In apps/api/src/routers/tablo.ts:
- Remove the
isAlreadyMemberErrorhelper (lines 21-29) - Remove the
upsertStreamUserFromProfilehelper (lines 31-47) - Remove the
ensureTabloChannelMemberhelper (lines 49-96) - In
createTablo(lines 98-170):- Remove
const streamServerClient = c.get("streamServerClient");and the channel.create block (lines 150-157)
- Remove
- In
updateTablo(lines 172-220):- Remove
const streamServerClient = c.get("streamServerClient");(line 176) - Remove the channel.update block (lines 207-217)
- Remove
- In
deleteTablo(lines 222-281):- Remove
const streamServerClient = c.get("streamServerClient");(line 225) - Remove the channel.delete block (lines 273-278)
- Remove
- In
inviteToTablo(lines 283-435):- Remove
const streamServerClient = c.get("streamServerClient");(line 291) - Remove
streamServerClientfromcreateInvitedUsercall (line 356-363) - Remove the
ensureTabloChannelMembercall (lines 384-389)
- Remove
- In
cancelPendingInvite(lines 437-526):- Remove
const streamServerClient = c.get("streamServerClient");(line 441) - Remove the channel.removeMembers block (lines 517-522)
- Remove
- In
acceptInviteById(lines 572-632):- Remove
const streamServerClient = c.get("streamServerClient");(line 576) - Remove the
upsertStreamUserFromProfilecall (lines 601-606) - Remove the
ensureTabloChannelMembercall (lines 624-629)
- Remove
- In
joinTablo(lines 634-697):- Remove
const streamServerClient = c.get("streamServerClient");(line 639) - Remove the
upsertStreamUserFromProfilecall (lines 660-665) - Remove the
ensureTabloChannelMembercall (lines 689-694)
- Remove
- In
leaveTablo(lines 748-768):- Remove
const streamServerClient = c.get("streamServerClient");(line 751) - Remove the
channel.removeMemberscall (lines 754-755)
- Remove
- In
getTabloRouter(lines 869-891):- Remove
tabloRouter.use(middlewareManager.streamChat);(line 875)
- Remove
- Step 8: Remove Stream from helpers.ts
In apps/api/src/helpers/helpers.ts:
- Remove
import type { StreamChat } from "stream-chat";(line 6) - In
createInvitedUser(lines 291-373):- Remove
streamServerClient: StreamChatfrom the parameter list (line 293) - Remove the
streamServerClient.upsertUser()call (lines 337-341)
- Remove
- Step 9: Remove stream-chat dependency
Run: cd apps/api && pnpm remove stream-chat
- Step 10: Update test files
In apps/api/src/__tests__/routes/tablo.test.ts:
- Remove the Stream Chat mock block (lines 12-38)
- Remove any
mockChannel*expectations in individual tests
In apps/api/src/__tests__/config/stripe-config.test.ts:
- Remove
streamChatApiSecretandstreamChatApiSecretStagingfrom mock data (lines 13, 16)
Update any other test files that reference Stream Chat mocks.
- Step 11: Run tests
Run: pnpm test:api
Expected: All tests pass with Stream Chat removed.
- Step 12: Commit
git add apps/api/
git commit -m "refactor(api): remove all Stream Chat dependencies and operations"
Task 11: Frontend — Remove Stream Chat dependencies
Files:
-
Delete:
apps/main/src/providers/ChatProvider.tsx -
Delete:
apps/main/src/components/ChannelPreview.tsx -
Delete:
apps/main/src/components/CustomChannelHeader.tsx -
Delete:
apps/main/src/hooks/channel.ts -
Modify:
apps/main/src/providers/UserStoreProvider.tsx— remove streamToken -
Modify:
apps/main/package.json— remove stream-chat, stream-chat-react -
Modify:
packages/shared/src/hooks/auth.ts— remove useSignUpToStream -
Step 1: Delete Stream-specific files
Delete these files:
-
apps/main/src/providers/ChatProvider.tsx -
apps/main/src/components/ChannelPreview.tsx -
apps/main/src/components/CustomChannelHeader.tsx -
apps/main/src/hooks/channel.ts -
Step 2: Remove streamToken from UserStoreProvider
In apps/main/src/providers/UserStoreProvider.tsx:
Change the User type (line 10-12) from:
export type User = Tables<"profiles"> & {
streamToken: string | null;
};
to:
export type User = Tables<"profiles">;
- Step 3: Remove useSignUpToStream from auth.ts
In packages/shared/src/hooks/auth.ts:
- Remove the
useSignUpToStreamfunction entirely (lines 85-101) - In
useSignUpWithoutPassword, remove:const { signUpToStream } = useSignUpToStream(api);(line 15)- The
signUpToStreamcall in the mutation (lines 38-40):if (response.session?.access_token) { await signUpToStream(response.session.access_token); }
- Step 4: Remove Stream packages
Run: cd apps/main && pnpm remove stream-chat stream-chat-react
- Step 5: Search for any remaining Stream references
Run: grep -r "stream-chat\|streamToken\|STREAM_CHAT\|StreamChat\|useChannelFromUrl\|useTabloDiscussionUnread\|ChatProvider" apps/main/src/ --include="*.ts" --include="*.tsx" -l
Expected: No files returned (only test mocks, if any).
- Step 6: Remove VITE_STREAM_CHAT_API_KEY from external app if present
Check apps/external/.env.production — if it references VITE_STREAM_CHAT_API_KEY, remove it.
- Step 7: Run typecheck and tests
Run: pnpm typecheck && cd apps/main && pnpm test
Expected: No type errors, all tests pass.
- Step 8: Commit
git add -A
git commit -m "refactor(main): remove all Stream Chat dependencies and components"
Task 12: Frontend — Remove Stream Chat API env var from API .env files
Files:
-
Modify:
apps/api/.env.development -
Step 1: Remove STREAM_CHAT_API_KEY
In apps/api/.env.development, remove line 3:
STREAM_CHAT_API_KEY=h7bwnn8ynjpx
- Step 2: Commit
git add apps/api/.env.development
git commit -m "chore: remove STREAM_CHAT_API_KEY from API env files"
Task 13: Integration testing — End-to-end chat flow
Files:
-
No new files — manual testing
-
Step 1: Start the chat worker locally
Run: cd apps/chat-worker && pnpm dev
Expected: Worker starts on http://localhost:8787 with DO bindings.
Note: Before this, set the required secrets locally:
cd apps/chat-worker
echo "your-supabase-service-role-key" | wrangler secret put SUPABASE_SERVICE_ROLE_KEY --local
echo "your-jwt-secret" | wrangler secret put JWT_SECRET --local
- Step 2: Start the main app
Run: pnpm dev:main
- Step 3: Test the golden path
- Log in to the app
- Navigate to
/chat - Select a tablo channel from the sidebar
- Send a message — verify it appears immediately (optimistic UI)
- Open a second browser tab with the same channel — verify the message appears there
- Send a message from the second tab — verify it appears in both tabs
- Type in one tab — verify typing indicator shows in the other
- Step 4: Test reconnection
- Stop the chat worker (
ctrl+cin the terminal runningpnpm devin chat-worker) - Verify the UI shows disconnected state
- Restart the chat worker
- Verify the client reconnects and loads missed messages
- Step 5: Test unread counts
- Open
/chatin one tab - Navigate away from the chat page in a second tab
- Send a message from the first tab
- In the second tab, the unread badge should appear within 30 seconds (polling interval)
- Step 6: Verify API has no Stream references
Run: pnpm dev:api
Run: Test various tablo operations (create, update, delete, invite) and verify they work without Stream Chat errors.
- Step 7: Run full test suite
Run: pnpm test
Expected: All tests pass.