diff --git a/.env.example b/.env.example index a402d55..2a60a62 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-secret DATABASE_URL=postgresql://user:password@localhost:5432/townhall NEXT_PUBLIC_API_URL=http://localhost:8080 +NEXT_PUBLIC_REALTIME_URL=http://localhost:8000 NODE_ENV=development PORT=8080 REALTIME_PORT=8000 diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 949efa0..fcb4e72 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -13,6 +13,7 @@ "@repo/auth": "workspace:*", "@repo/db": "workspace:*", "@repo/env": "workspace:*", + "@repo/realtime-types": "workspace:*", "socket.io": "^4.8.1", "zod": "^4.3.6" }, diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index a533409..fb209df 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -2,19 +2,21 @@ import { createServer } from "node:http" import { auth, type Session } from "@repo/auth" import { db, eq, schema } from "@repo/db" import { env } from "@repo/env/server" -import { Server, type Socket } from "socket.io" -import { toErrorMessage } from "@/lib/errors" import type { ClientToServerEvents, InterServerEvents, ServerToClientEvents, -} from "@/lib/events" +} from "@repo/realtime-types" import { + channelRoom, channelRoomPayloadSchema, + guildRoom, markChannelReadPayloadSchema, sendMessagePayloadSchema, -} from "@/lib/events" -import { channelRoom, guildRoom, userRoom } from "@/lib/rooms" + userRoom, +} from "@repo/realtime-types" +import { Server, type Socket } from "socket.io" +import { toErrorMessage } from "@/lib/errors" import { assertUserCanAccessChannel } from "@/services/channel-access" import { createMessage } from "@/services/messages" import { buildMessageFanout } from "@/services/notifications" diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 6441f9c..4259436 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -1,5 +1,5 @@ import { db, eq, schema } from "@repo/db" -import type { RealtimeMessage, SendMessagePayload } from "@/lib/events" +import type { RealtimeMessage, SendMessagePayload } from "@repo/realtime-types" import { type AccessibleChannel, assertUserCanAccessChannel, diff --git a/apps/realtime/src/services/notifications.ts b/apps/realtime/src/services/notifications.ts index f24eff5..d986f94 100644 --- a/apps/realtime/src/services/notifications.ts +++ b/apps/realtime/src/services/notifications.ts @@ -3,7 +3,7 @@ import type { MentionNotification, RealtimeMessage, UnreadNotification, -} from "@/lib/events" +} from "@repo/realtime-types" import type { AccessibleChannel } from "./channel-access" type MessageFanoutInput = { diff --git a/apps/realtime/src/services/read-states.ts b/apps/realtime/src/services/read-states.ts index 0ca83cf..367b729 100644 --- a/apps/realtime/src/services/read-states.ts +++ b/apps/realtime/src/services/read-states.ts @@ -1,5 +1,5 @@ import { and, count, db, desc, eq, gt, ne, schema, sql } from "@repo/db" -import type { ChannelReadState } from "@/lib/events" +import type { ChannelReadState } from "@repo/realtime-types" import { assertUserCanAccessChannel } from "./channel-access" type MarkChannelReadInput = { diff --git a/apps/web/package.json b/apps/web/package.json index cc22255..4688d6c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@repo/api-client": "workspace:*", "@repo/auth": "workspace:*", "@repo/env": "workspace:*", + "@repo/realtime-types": "workspace:*", "@repo/ui": "workspace:*", "@repo/utils": "workspace:*", "@tailwindcss/postcss": "^4.1.18", @@ -30,6 +31,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "zod": "^4.3.6" diff --git a/apps/web/src/components/chat/chat-skeleton.tsx b/apps/web/src/components/chat/chat-skeleton.tsx new file mode 100644 index 0000000..b6303d3 --- /dev/null +++ b/apps/web/src/components/chat/chat-skeleton.tsx @@ -0,0 +1,55 @@ +import { Skeleton } from "@repo/ui/components/skeleton" + +const SKELETON_GROUPS = [ + { key: "a", nameWidth: "5rem", lines: ["80%", "45%"] }, + { key: "b", nameWidth: "6.5rem", lines: ["60%"] }, + { key: "c", nameWidth: "4rem", lines: ["90%", "70%", "35%"] }, + { key: "d", nameWidth: "5.5rem", lines: ["50%"] }, + { key: "e", nameWidth: "7rem", lines: ["75%", "55%"] }, + { key: "f", nameWidth: "4.5rem", lines: ["65%"] }, + { key: "g", nameWidth: "6rem", lines: ["85%", "40%"] }, +] + +export function ChatSkeleton() { + return ( +
+ {/* Header skeleton */} +
+ + +
+ + {/* Messages skeleton */} +
+ {SKELETON_GROUPS.map((group) => ( +
+
+ +
+
+ + +
+ {group.lines.map((width, i) => ( + + ))} +
+
+
+ ))} +
+ + {/* Input skeleton */} +
+ +
+
+ ) +} diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index 58d3ef4..0e5cea1 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -1,4 +1,6 @@ +import { Skeleton } from "@repo/ui/components/skeleton" import { Hash, User, Users } from "lucide-react" +import { useCallback, useEffect, useRef } from "react" import type { Message } from "@/lib/api-types" import type { ChatContext } from "./header" import { MessageItem } from "./message-item" @@ -39,6 +41,16 @@ function EmptyState({ context }: { context: ChatContext }) { ) } +const MESSAGE_SKELETON_GROUPS = [ + { key: "a", nameWidth: "5rem", lines: ["80%", "45%"] }, + { key: "b", nameWidth: "6.5rem", lines: ["60%"] }, + { key: "c", nameWidth: "4rem", lines: ["90%", "70%", "35%"] }, + { key: "d", nameWidth: "5.5rem", lines: ["50%"] }, + { key: "e", nameWidth: "7rem", lines: ["75%", "55%"] }, + { key: "f", nameWidth: "4.5rem", lines: ["65%"] }, + { key: "g", nameWidth: "6rem", lines: ["85%", "40%"] }, +] + export function MessageList({ context, messages, @@ -46,16 +58,52 @@ export function MessageList({ hasMore, onLoadMore, }: MessageListProps) { + const scrollRef = useRef(null) + const isNearBottom = useRef(true) + const prevMessageCount = useRef(messages.length) + + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + // flex-col-reverse: scrollTop is 0 at bottom, negative when scrolled up + isNearBottom.current = Math.abs(el.scrollTop) < 150 + }, []) + + useEffect(() => { + // Always scroll on initial load (count went from 0 to N), otherwise only if near bottom + if (prevMessageCount.current === 0 || isNearBottom.current) { + scrollRef.current?.scrollTo({ top: 0 }) + } + prevMessageCount.current = messages.length + }, [messages.length]) + if (isLoading) { return ( - - - Loading messages... - - +
+ {MESSAGE_SKELETON_GROUPS.map((group) => ( +
+
+ +
+
+ + +
+ {group.lines.map((width, i) => ( + + ))} +
+
+
+ ))} +
) } @@ -64,7 +112,11 @@ export function MessageList({ } return ( -
+
{messages.map((msg, i) => { const next = messages[i + 1] const showHeader = !next || next.authorId !== msg.authorId diff --git a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx index db87e33..4ec17c3 100644 --- a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx @@ -8,8 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@repo/ui/components/dropdown-menu" -import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/toggle-group" -import { useNavigate } from "@tanstack/react-router" +import { cn } from "@repo/ui/lib/utils" import { ChevronsUpDown, Laptop, @@ -18,14 +17,65 @@ import { Settings, Sun, } from "lucide-react" +import { LayoutGroup, motion } from "motion/react" import { useTheme } from "next-themes" import { UserAvatar } from "../../ui/user-avatar" +const themes = [ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Laptop, label: "System" }, +] as const + +function ThemeSwitcher({ + theme, + setTheme, +}: { + theme: string | undefined + setTheme: (theme: string) => void +}) { + return ( + +
+ {themes.map(({ value, icon: Icon, label }) => { + const isActive = theme === value + return ( + + ) + })} +
+
+ ) +} + export function UserBar() { const { data: session } = authClient.useSession() const { setTheme, theme } = useTheme() - const navigate = useNavigate() - const name = session?.user.name ?? "User" const email = session?.user.email ?? "" @@ -39,7 +89,6 @@ export function UserBar() { const handleOpenSettings = () => { // TODO: Navigate to settings page - // navigate({ to: "/settings" }) } return ( @@ -89,32 +138,7 @@ export function UserBar() { onSelect={(e) => e.preventDefault()} > Theme - value && setTheme(value)} - > - - - - - - - - - - + diff --git a/apps/web/src/context/socket-context.tsx b/apps/web/src/context/socket-context.tsx new file mode 100644 index 0000000..38b552b --- /dev/null +++ b/apps/web/src/context/socket-context.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react" +import { createContext, useContext, useEffect, useState } from "react" +import { type AppSocket, getSocket } from "@/lib/socket" + +const SocketContext = createContext(null) + +export function useSocket() { + return useContext(SocketContext) +} + +export function SocketProvider({ + enabled, + children, +}: { + enabled: boolean + children: ReactNode +}) { + const [socket, setSocket] = useState(null) + + useEffect(() => { + if (!enabled) return + + const s = getSocket() + + const onConnect = () => { + console.log("[socket] connected:", s.id) + } + const onDisconnect = (reason: string) => { + console.log("[socket] disconnected:", reason) + } + const onConnectError = (err: Error) => { + console.error("[socket] connection error:", err.message) + } + + s.on("connect", onConnect) + s.on("disconnect", onDisconnect) + s.on("connect_error", onConnectError) + + s.connect() + setSocket(s) + + return () => { + s.off("connect", onConnect) + s.off("disconnect", onDisconnect) + s.off("connect_error", onConnectError) + s.disconnect() + setSocket(null) + } + }, [enabled]) + + return ( + {children} + ) +} diff --git a/apps/web/src/lib/realtime-adapter.ts b/apps/web/src/lib/realtime-adapter.ts new file mode 100644 index 0000000..40440ca --- /dev/null +++ b/apps/web/src/lib/realtime-adapter.ts @@ -0,0 +1,45 @@ +import type { RealtimeMessage } from "@repo/realtime-types" +import type { Message, MessageAuthor } from "./api-types" + +export function realtimeMessageToMessage(rm: RealtimeMessage): Message { + return { + id: rm.id, + channelId: rm.channelId, + authorId: rm.author.id, + content: rm.content, + type: rm.type, + createdAt: rm.createdAt, + author: rm.author, + referencedMessageId: null, + attachments: [], + embeds: [], + pinned: false, + editedAt: null, + } +} + +/** + * Creates an optimistic message that appears instantly in the UI. + * Uses `nonce` as the temporary `id` so it can be replaced when the server confirms. + */ +export function createOptimisticMessage( + nonce: string, + channelId: string, + content: string, + author: MessageAuthor +): Message { + return { + id: nonce, + channelId, + authorId: author.id, + content, + type: "default", + createdAt: new Date().toISOString(), + author, + referencedMessageId: null, + attachments: [], + embeds: [], + pinned: false, + editedAt: null, + } +} diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts new file mode 100644 index 0000000..da61cab --- /dev/null +++ b/apps/web/src/lib/socket.ts @@ -0,0 +1,21 @@ +import { env } from "@repo/env/client" +import type { + ClientToServerEvents, + ServerToClientEvents, +} from "@repo/realtime-types" +import { io, type Socket } from "socket.io-client" + +export type AppSocket = Socket + +let socket: AppSocket | null = null + +export function getSocket(): AppSocket { + if (!socket) { + socket = io(env.NEXT_PUBLIC_REALTIME_URL, { + autoConnect: false, + withCredentials: true, + transports: ["websocket"], + }) + } + return socket +} diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index f58a948..1a062a6 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -9,6 +9,7 @@ import { import { useEffect } from "react" import { OnboardingDialog } from "../components/onboarding/onboarding-dialog" import { Sidebar } from "../components/sidebar" +import { SocketProvider } from "../context/socket-context" const LAST_PATH_KEY = "townhall:last-path" @@ -62,11 +63,13 @@ function AuthenticatedLayout() { guilds?.length === 0 return ( -
- - - - -
+ +
+ + + + +
+
) } diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 96aba6c..6f421d6 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -1,9 +1,18 @@ -import { useQuery } from "@tanstack/react-query" +import { authClient } from "@repo/auth/client" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" +import { useCallback, useEffect, useRef } from "react" +import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { ChatHeader } from "@/components/chat/header" import { MessageInput } from "@/components/chat/message-input" import { MessageList } from "@/components/chat/message-list" +import { useSocket } from "@/context/socket-context" import { apiClient } from "@/lib/api-client" +import type { ListMessagesResponse } from "@/lib/api-types" +import { + createOptimisticMessage, + realtimeMessageToMessage, +} from "@/lib/realtime-adapter" export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ component: ChannelView, @@ -11,6 +20,11 @@ export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ function ChannelView() { const { guildSlug, channelId } = Route.useParams() + const socket = useSocket() + const queryClient = useQueryClient() + const { data: session } = authClient.useSession() + // Track nonces for optimistic messages so we can replace them on confirm + const pendingNonces = useRef(new Set()) const { data, isPending, isError, error } = useQuery({ queryKey: ["channel", guildSlug, channelId], @@ -25,12 +39,137 @@ function ChannelView() { }, }) + const { data: messagesData, isPending: messagesLoading } = useQuery({ + queryKey: ["messages", channelId], + queryFn: async () => { + const res = await apiClient.v1.guilds[":guildSlug"].channels[ + ":channelId" + ].messages.$get({ + param: { guildSlug, channelId }, + query: {}, + }) + if (!res.ok) throw new Error("Failed to fetch messages") + return res.json() + }, + enabled: !!data, + }) + + // Join/leave the channel room for real-time messages + useEffect(() => { + if (!socket) return + + socket.emit("channel:join", { channelId }) + + return () => { + socket.emit("channel:leave", { channelId }) + } + }, [socket, channelId]) + + // Listen for incoming messages + useEffect(() => { + if (!socket) return + + const handleMessageCreated = ( + msg: Parameters[0] + ) => { + if (msg.channelId !== channelId) return + + queryClient.setQueryData( + ["messages", channelId], + (old) => { + if (!old) return old + // If this message was sent by us, replace the optimistic entry + if (msg.nonce && pendingNonces.current.has(msg.nonce)) { + pendingNonces.current.delete(msg.nonce) + return { + ...old, + data: old.data.map((m) => + m.id === msg.nonce ? realtimeMessageToMessage(msg) : m + ), + } + } + // Otherwise it's from someone else — prepend if not already present + if (old.data.some((m) => m.id === msg.id)) return old + return { + ...old, + data: [realtimeMessageToMessage(msg), ...old.data], + } + } + ) + } + + socket.on("message:created", handleMessageCreated) + return () => { + socket.off("message:created", handleMessageCreated) + } + }, [socket, channelId, queryClient]) + + const handleSend = useCallback( + (content: string) => { + if (!socket?.connected || !session?.user) return + + const nonce = crypto.randomUUID() + pendingNonces.current.add(nonce) + + const author = { + id: session.user.id, + name: session.user.name, + username: session.user.username ?? null, + displayUsername: session.user.displayUsername ?? null, + image: session.user.image ?? null, + } + + // Insert optimistic message immediately + queryClient.setQueryData( + ["messages", channelId], + (old) => { + if (!old) return old + return { + ...old, + data: [ + createOptimisticMessage(nonce, channelId, content, author), + ...old.data, + ], + } + } + ) + + socket.emit("message:send", { channelId, content, nonce }, (result) => { + if (!result.ok) { + console.error("[chat] send failed:", result.error) + // Remove the optimistic message on failure + pendingNonces.current.delete(nonce) + queryClient.setQueryData( + ["messages", channelId], + (old) => { + if (!old) return old + return { ...old, data: old.data.filter((m) => m.id !== nonce) } + } + ) + return + } + + // Replace optimistic message with the confirmed one + pendingNonces.current.delete(nonce) + queryClient.setQueryData( + ["messages", channelId], + (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((m) => + m.id === nonce ? realtimeMessageToMessage(result.message) : m + ), + } + } + ) + }) + }, + [socket, channelId, queryClient, session] + ) + if (isPending) { - return ( -
- Loading... -
- ) + return } if (isError) { @@ -60,8 +199,12 @@ function ChannelView() { return (
- - {}} /> + +
) } diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index e8de5d1..7a25727 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -1,9 +1,18 @@ -import { useQuery } from "@tanstack/react-query" +import { authClient } from "@repo/auth/client" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" +import { useCallback, useEffect, useRef } from "react" +import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { ChatHeader } from "@/components/chat/header" import { MessageInput } from "@/components/chat/message-input" import { MessageList } from "@/components/chat/message-list" +import { useSocket } from "@/context/socket-context" import { apiClient } from "@/lib/api-client" +import type { ListDMMessagesResponse } from "@/lib/api-types" +import { + createOptimisticMessage, + realtimeMessageToMessage, +} from "@/lib/realtime-adapter" export const Route = createFileRoute("/_authenticated/dms/$dmId")({ component: DMConversation, @@ -11,6 +20,10 @@ export const Route = createFileRoute("/_authenticated/dms/$dmId")({ function DMConversation() { const { dmId } = Route.useParams() + const socket = useSocket() + const queryClient = useQueryClient() + const { data: session } = authClient.useSession() + const pendingNonces = useRef(new Set()) const { data: dm, isPending } = useQuery({ queryKey: ["dms", dmId], @@ -21,12 +34,134 @@ function DMConversation() { }, }) + const { data: messagesData, isPending: messagesLoading } = useQuery({ + queryKey: ["messages", dmId], + queryFn: async () => { + const res = await apiClient.v1.dms[":dmId"].messages.$get({ + param: { dmId }, + query: {}, + }) + if (!res.ok) throw new Error("Failed to fetch messages") + return res.json() + }, + enabled: !!dm, + }) + + // Join/leave the DM channel room for real-time messages + useEffect(() => { + if (!socket) return + + socket.emit("channel:join", { channelId: dmId }) + + return () => { + socket.emit("channel:leave", { channelId: dmId }) + } + }, [socket, dmId]) + + // Listen for incoming messages + useEffect(() => { + if (!socket) return + + const handleMessageCreated = ( + msg: Parameters[0] + ) => { + if (msg.channelId !== dmId) return + + queryClient.setQueryData( + ["messages", dmId], + (old) => { + if (!old) return old + if (msg.nonce && pendingNonces.current.has(msg.nonce)) { + pendingNonces.current.delete(msg.nonce) + return { + ...old, + data: old.data.map((m) => + m.id === msg.nonce ? realtimeMessageToMessage(msg) : m + ), + } + } + if (old.data.some((m) => m.id === msg.id)) return old + return { + ...old, + data: [realtimeMessageToMessage(msg), ...old.data], + } + } + ) + } + + socket.on("message:created", handleMessageCreated) + return () => { + socket.off("message:created", handleMessageCreated) + } + }, [socket, dmId, queryClient]) + + const handleSend = useCallback( + (content: string) => { + if (!socket?.connected || !session?.user) return + + const nonce = crypto.randomUUID() + pendingNonces.current.add(nonce) + + const author = { + id: session.user.id, + name: session.user.name, + username: session.user.username ?? null, + displayUsername: session.user.displayUsername ?? null, + image: session.user.image ?? null, + } + + queryClient.setQueryData( + ["messages", dmId], + (old) => { + if (!old) return old + return { + ...old, + data: [ + createOptimisticMessage(nonce, dmId, content, author), + ...old.data, + ], + } + } + ) + + socket.emit( + "message:send", + { channelId: dmId, content, nonce }, + (result) => { + if (!result.ok) { + console.error("[chat] send failed:", result.error) + pendingNonces.current.delete(nonce) + queryClient.setQueryData( + ["messages", dmId], + (old) => { + if (!old) return old + return { ...old, data: old.data.filter((m) => m.id !== nonce) } + } + ) + return + } + + pendingNonces.current.delete(nonce) + queryClient.setQueryData( + ["messages", dmId], + (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((m) => + m.id === nonce ? realtimeMessageToMessage(result.message) : m + ), + } + } + ) + } + ) + }, + [socket, dmId, queryClient, session] + ) + if (isPending) { - return ( -
- Loading... -
- ) + return } if (!dm) { @@ -61,8 +196,12 @@ function DMConversation() { return (
- - {}} /> + +
) } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 604aa9e..d579bf4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -21,6 +21,9 @@ export default defineConfig(({ mode }) => { "process.env.NEXT_PUBLIC_API_URL": JSON.stringify( env.NEXT_PUBLIC_API_URL ), + "process.env.NEXT_PUBLIC_REALTIME_URL": JSON.stringify( + env.NEXT_PUBLIC_REALTIME_URL + ), "process.env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE": JSON.stringify( env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE ), diff --git a/packages/env/src/client.ts b/packages/env/src/client.ts index fec7a20..a7c4239 100644 --- a/packages/env/src/client.ts +++ b/packages/env/src/client.ts @@ -3,11 +3,10 @@ import { z } from "zod" /** 20 MB default — keep in sync with server.ts */ const DEFAULT_MAX_FILE_UPLOAD_SIZE = 20 * 1024 * 1024 -/** Adds a protocol to a URL if missing. Defaults to http:// for localhost/loopback, https:// otherwise. */ +/** Adds a protocol to a URL if missing. Defaults to http:// for localhost/loopback, https:// otherwise. Preserves ws:// and wss:// schemes. */ const addProtocol = (url: string) => { const trimmed = url.trim() - if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) - return trimmed + if (/^(https?|wss?):\/\//i.test(trimmed)) return trimmed const isLocal = trimmed.startsWith("localhost") || trimmed.startsWith("127.0.0.1") return isLocal ? `http://${trimmed}` : `https://${trimmed}` @@ -18,6 +17,7 @@ const clientSchema = z.object({ .enum(["development", "staging", "production", "test"]) .default("production"), NEXT_PUBLIC_API_URL: z.string().min(1).transform(addProtocol), + NEXT_PUBLIC_REALTIME_URL: z.string().min(1).transform(addProtocol), NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE: z.coerce .number() .default(DEFAULT_MAX_FILE_UPLOAD_SIZE), @@ -26,6 +26,7 @@ const clientSchema = z.object({ export const env = clientSchema.parse({ NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_REALTIME_URL: process.env.NEXT_PUBLIC_REALTIME_URL, NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE: process.env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE, }) diff --git a/packages/realtime-types/package.json b/packages/realtime-types/package.json new file mode 100644 index 0000000..c5828d4 --- /dev/null +++ b/packages/realtime-types/package.json @@ -0,0 +1,20 @@ +{ + "name": "@repo/realtime-types", + "version": "0.0.0", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts", + "./events": "./src/events.ts", + "./rooms": "./src/rooms.ts" + }, + "scripts": { + "check-types": "tsc --noEmit" + }, + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*" + } +} diff --git a/apps/realtime/src/lib/events.ts b/packages/realtime-types/src/events.ts similarity index 100% rename from apps/realtime/src/lib/events.ts rename to packages/realtime-types/src/events.ts diff --git a/packages/realtime-types/src/index.ts b/packages/realtime-types/src/index.ts new file mode 100644 index 0000000..dffcb93 --- /dev/null +++ b/packages/realtime-types/src/index.ts @@ -0,0 +1,2 @@ +export * from "./events" +export * from "./rooms" diff --git a/apps/realtime/src/lib/rooms.ts b/packages/realtime-types/src/rooms.ts similarity index 100% rename from apps/realtime/src/lib/rooms.ts rename to packages/realtime-types/src/rooms.ts diff --git a/packages/realtime-types/tsconfig.json b/packages/realtime-types/tsconfig.json new file mode 100644 index 0000000..45135ea --- /dev/null +++ b/packages/realtime-types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0bf64f..7b9a4f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@repo/env': specifier: workspace:* version: link:../../packages/env + '@repo/realtime-types': + specifier: workspace:* + version: link:../../packages/realtime-types socket.io: specifier: ^4.8.1 version: 4.8.3 @@ -121,11 +124,14 @@ importers: '@repo/env': specifier: workspace:* version: link:../../packages/env + '@repo/realtime-types': + specifier: workspace:* + version: link:../../packages/realtime-types '@repo/ui': specifier: workspace:* version: link:../../packages/ui '@repo/utils': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/utils '@tailwindcss/postcss': specifier: ^4.1.18 @@ -163,6 +169,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -344,6 +353,16 @@ importers: specifier: workspace:* version: link:../typescript-config + packages/realtime-types: + dependencies: + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@repo/typescript-config': + specifier: workspace:* + version: link:../typescript-config + packages/typescript-config: {} packages/ui: @@ -3049,6 +3068,9 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} @@ -4194,6 +4216,10 @@ packages: socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + socket.io-parser@4.2.5: resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} engines: {node: '>=10.0.0'} @@ -4591,6 +4617,10 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6877,6 +6907,18 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + engine.io-parser@5.2.3: {} engine.io@6.6.5: @@ -8162,6 +8204,17 @@ snapshots: - supports-color - utf-8-validate + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.4 + socket.io-parser: 4.2.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-parser@4.2.5: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -8498,6 +8551,8 @@ snapshots: is-wsl: 3.1.0 powershell-utils: 0.1.0 + xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} yallist@3.1.1: {}