├── app ├── app.css ├── routes.ts ├── routes │ ├── ws.ts │ ├── api │ │ ├── get-image.ts │ │ └── image.ts │ └── home.tsx ├── entry.server.tsx ├── root.tsx └── stores │ └── chat.ts ├── docs └── image.png ├── public └── favicon.ico ├── .gitignore ├── react-router.config.ts ├── drizzle.config.ts ├── drizzle ├── migrations │ ├── index.ts │ ├── meta │ │ ├── _journal.json │ │ └── 0000_snapshot.json │ └── 0000_flawless_abomination.sql ├── utils.ts └── schema.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── biome.json ├── workers ├── app.ts ├── types.ts └── ws.ts ├── tsconfig.cloudflare.json ├── wrangler.jsonc ├── package.json ├── README.md └── pnpm-lock.yaml /app/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /docs/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akazwz/anonymous-chat/HEAD/docs/image.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akazwz/anonymous-chat/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | *.tsbuildinfo 4 | 5 | # React Router 6 | /.react-router/ 7 | /build/ 8 | 9 | # Cloudflare 10 | .mf 11 | .wrangler 12 | .dev.vars* 13 | 14 | .idea/ 15 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | ssr: true, 5 | future: { 6 | unstable_viteEnvironmentApi: true, 7 | }, 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | dialect: "sqlite", 5 | schema: "drizzle/schema.ts", 6 | out: "drizzle/migrations", 7 | casing: "snake_case", 8 | }); 9 | -------------------------------------------------------------------------------- /drizzle/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import journal from "./meta/_journal.json"; 2 | import m0000 from "./0000_flawless_abomination.sql?raw"; 3 | 4 | export default { 5 | journal, 6 | migrations: { 7 | m0000, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /drizzle/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1742959940273, 9 | "tag": "0000_flawless_abomination", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig, index, route } from "@react-router/dev/routes"; 2 | 3 | export default [ 4 | index("routes/home.tsx"), 5 | route("/ws", "routes/ws.ts"), 6 | route("/api/image", "routes/api/image.ts"), 7 | route("/api/images/:key", "routes/api/get-image.ts"), 8 | ] satisfies RouteConfig; 9 | -------------------------------------------------------------------------------- /app/routes/ws.ts: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/ws"; 2 | 3 | export async function loader({ request, context }: Route.LoaderArgs) { 4 | const env = context.cloudflare.env; 5 | const chatServer = env.CHAT_SERVER.idFromName("chat-server"); 6 | const stub = env.CHAT_SERVER.get(chatServer); 7 | return stub.fetch(request); 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.node.json" }, 5 | { "path": "./tsconfig.cloudflare.json" } 6 | ], 7 | "compilerOptions": { 8 | "checkJs": true, 9 | "verbatimModuleSyntax": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["tailwind.config.ts", "vite.config.ts"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "strict": true, 7 | "types": ["node"], 8 | "lib": ["ES2022"], 9 | "target": "ES2022", 10 | "module": "ES2022", 11 | "moduleResolution": "bundler" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import { cloudflare } from "@cloudflare/vite-plugin"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { defineConfig } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | cloudflare({ viteEnvironment: { name: "ssr" } }), 10 | tailwindcss(), 11 | reactRouter(), 12 | tsconfigPaths(), 13 | ], 14 | }); 15 | -------------------------------------------------------------------------------- /drizzle/migrations/0000_flawless_abomination.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `messages` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `user_id` text NOT NULL, 4 | `message_type` text NOT NULL, 5 | `content` text NOT NULL, 6 | `metadata` text, 7 | `created_at` integer NOT NULL, 8 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE `users` ( 12 | `id` text PRIMARY KEY NOT NULL, 13 | `name` text NOT NULL, 14 | `created_at` integer NOT NULL 15 | ); 16 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["worker-configuration.d.ts"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true 23 | } 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "quoteStyle": "double" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /workers/app.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "react-router"; 2 | export { ChatServer } from "./ws"; 3 | 4 | declare global { 5 | interface CloudflareEnvironment extends Env {} 6 | } 7 | 8 | declare module "react-router" { 9 | export interface AppLoadContext { 10 | cloudflare: { 11 | env: CloudflareEnvironment; 12 | ctx: ExecutionContext; 13 | }; 14 | } 15 | } 16 | 17 | const requestHandler = createRequestHandler( 18 | () => import("virtual:react-router/server-build"), 19 | import.meta.env.MODE, 20 | ); 21 | 22 | export default { 23 | async fetch(request, env, ctx) { 24 | return requestHandler(request, { 25 | cloudflare: { env, ctx }, 26 | }); 27 | }, 28 | } satisfies ExportedHandler; 29 | -------------------------------------------------------------------------------- /tsconfig.cloudflare.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | ".react-router/types/**/*", 5 | "app/**/*", 6 | "app/**/.server/**/*", 7 | "app/**/.client/**/*", 8 | "workers/**/*", 9 | "worker-configuration.d.ts", 10 | "drizzle/**/*" 11 | ], 12 | "compilerOptions": { 13 | "composite": true, 14 | "strict": true, 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 16 | "types": ["vite/client"], 17 | "target": "ES2022", 18 | "module": "ES2022", 19 | "moduleResolution": "bundler", 20 | "jsx": "react-jsx", 21 | "baseUrl": ".", 22 | "rootDirs": [".", "./.react-router/types"], 23 | "paths": { 24 | "~/*": ["./app/*"] 25 | }, 26 | "esModuleInterop": true, 27 | "resolveJsonModule": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "anonymous-chat", 4 | "compatibility_date": "2025-02-24", 5 | "main": "./workers/app.ts", 6 | "durable_objects": { 7 | "bindings": [ 8 | { 9 | "name": "CHAT_SERVER", 10 | "class_name": "ChatServer" 11 | } 12 | ] 13 | }, 14 | "migrations": [ 15 | { 16 | "tag": "v1", 17 | "new_sqlite_classes": ["ChatServer"] 18 | } 19 | ], 20 | "rules": [ 21 | { 22 | "type": "Text", 23 | "globs": ["**/*.sql"], 24 | "fallthrough": true 25 | } 26 | ], 27 | "r2_buckets": [ 28 | { 29 | "binding": "BUCKET", 30 | "bucket_name": "anonymous-chat-media", 31 | "preview_bucket_name": "anonymous-chat-media" 32 | } 33 | ], 34 | "images": { 35 | "binding": "IMAGES" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/routes/api/get-image.ts: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/get-image"; 2 | 3 | export async function loader({ params, context }: Route.LoaderArgs) { 4 | const { key } = params; 5 | try { 6 | const env = context.cloudflare.env; 7 | const object = await env.BUCKET.get(key); 8 | 9 | if (!object) { 10 | return new Response("Image not found", { status: 404 }); 11 | } 12 | 13 | // Create a response with the image data and appropriate headers 14 | const headers = new Headers(); 15 | headers.set( 16 | "Content-Type", 17 | object.httpMetadata?.contentType || "image/webp", 18 | ); 19 | headers.set("Cache-Control", "public, max-age=31536000"); // Cache for 1 year 20 | 21 | return new Response(object.body, { 22 | headers, 23 | }); 24 | } catch (error) { 25 | console.error("Error fetching image:", error); 26 | return new Response("Error fetching image", { status: 500 }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anonymous-chat", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "react-router build", 7 | "deploy": "npm run build && wrangler deploy", 8 | "dev": "react-router dev", 9 | "preview": "vite preview", 10 | "start": "wrangler dev --experimental-images-local-mode", 11 | "typecheck": "wrangler types && react-router typegen && tsc -b", 12 | "format": "biome format --write ." 13 | }, 14 | "dependencies": { 15 | "date-fns": "^4.1.0", 16 | "drizzle-orm": "^0.41.0", 17 | "isbot": "^5.1.25", 18 | "react": "^19.0.0", 19 | "react-dom": "^19.0.0", 20 | "react-router": "^7.4.0", 21 | "zustand": "^5.0.3" 22 | }, 23 | "devDependencies": { 24 | "@biomejs/biome": "^1.9.4", 25 | "@cloudflare/vite-plugin": "^0.1.17", 26 | "@react-router/dev": "^7.4.0", 27 | "@tailwindcss/vite": "^4.0.17", 28 | "@types/node": "^22.13.14", 29 | "@types/react": "^19.0.12", 30 | "@types/react-dom": "^19.0.4", 31 | "drizzle-kit": "^0.30.5", 32 | "tailwindcss": "^4.0.17", 33 | "typescript": "^5.8.2", 34 | "vite": "^6.2.3", 35 | "vite-tsconfig-paths": "^5.1.4", 36 | "wrangler": "^4.5.0" 37 | }, 38 | "pnpm": { 39 | "onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "workerd"] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /workers/types.ts: -------------------------------------------------------------------------------- 1 | import type { User, Message, MessageType } from "../drizzle/schema"; 2 | 3 | // 在线人数消息 4 | export interface OnlineCountMessage { 5 | type: "online_count"; 6 | count: number; 7 | } 8 | 9 | // 初始化消息 10 | export interface InitMessage { 11 | type: "init"; 12 | user: User; 13 | } 14 | 15 | // 用户上线消息 16 | export interface UserJoinMessage { 17 | type: "user_join"; 18 | user: User; 19 | } 20 | 21 | // 用户下线消息 22 | export interface UserLeaveMessage { 23 | type: "user_leave"; 24 | user: User; 25 | } 26 | 27 | // 聊天消息 28 | export interface ChatMessage { 29 | type: "message"; 30 | message: Message; 31 | sender?: { 32 | id: string; 33 | name: string; 34 | }; 35 | } 36 | 37 | // 历史消息 38 | export interface HistoryMessagesMessage { 39 | type: "history_messages"; 40 | messages: Message[]; 41 | } 42 | 43 | // 所有可能的消息类型联合 44 | export type WebSocketMessage = 45 | | OnlineCountMessage 46 | | InitMessage 47 | | ChatMessage 48 | | UserJoinMessage 49 | | UserLeaveMessage 50 | | HistoryMessagesMessage; 51 | 52 | // 客户端发送到服务端的消息类型 53 | export type ClientToServerMessage = { 54 | type: "message"; 55 | content: string; 56 | messageType: keyof typeof MessageType; 57 | metadata?: { 58 | width?: number; 59 | height?: number; 60 | mimeType?: string; 61 | fileSize?: number; 62 | thumbnailUrl?: string; 63 | }; // 元数据,用于图片等特殊消息类型 64 | }; 65 | -------------------------------------------------------------------------------- /drizzle/utils.ts: -------------------------------------------------------------------------------- 1 | export function getRandomName(): string { 2 | const adjectives = [ 3 | "快乐", 4 | "聪明", 5 | "勇敢", 6 | "温柔", 7 | "睿智", 8 | "幸运", 9 | "善良", 10 | "酷酷", 11 | "平静", 12 | "明亮", 13 | "机智", 14 | "敏捷", 15 | "高贵", 16 | "甜美", 17 | "欢乐", 18 | "愉快", 19 | "骄傲", 20 | "热情", 21 | "机灵", 22 | "阳光", 23 | "活泼", 24 | "调皮", 25 | "友好", 26 | "安静", 27 | "开心", 28 | "优雅", 29 | "诚实", 30 | "可爱", 31 | "迷人", 32 | "大胆", 33 | "活力", 34 | "有趣", 35 | "谦逊", 36 | "神奇", 37 | "强大", 38 | "耐心", 39 | "华丽", 40 | "闪亮", 41 | "俏皮", 42 | "可爱", 43 | ]; 44 | const animals = [ 45 | "猫咪", 46 | "狗狗", 47 | "狐狸", 48 | "熊熊", 49 | "猫头鹰", 50 | "狼", 51 | "狮子", 52 | "老虎", 53 | "熊猫", 54 | "兔子", 55 | "小鹿", 56 | "鸭子", 57 | "小鸟", 58 | "鱼儿", 59 | "海豹", 60 | "海豚", 61 | "企鹅", 62 | "考拉", 63 | "袋鼠", 64 | "大象", 65 | "长颈鹿", 66 | "猴子", 67 | "斑马", 68 | "刺猬", 69 | "松鼠", 70 | "仓鼠", 71 | "浣熊", 72 | "蝴蝶", 73 | "龙", 74 | "独角兽", 75 | "鲸鱼", 76 | "水獭", 77 | "小马", 78 | "羊驼", 79 | "海狸", 80 | "猎豹", 81 | "老鹰", 82 | "隼", 83 | "羚羊", 84 | "河马", 85 | ]; 86 | const randomAdjective = 87 | adjectives[Math.floor(Math.random() * adjectives.length)]; 88 | const randomAnimal = animals[Math.floor(Math.random() * animals.length)]; 89 | return `${randomAdjective}${randomAnimal}`; 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anonymous Chat 匿名聊天室 2 | 3 | 一个基于 React 和 Cloudflare Workers 构建的实时匿名聊天应用。用户可以在无需注册的情况下快速加入聊天,系统会自动分配一个随机的有趣昵称。 4 | 5 | ## ✨ 特性 6 | 7 | - 🎭 完全匿名 - 自动生成有趣的随机昵称 8 | - 🔄 实时通讯 - 基于 WebSocket 的即时消息传递 9 | - 👥 在线状态 - 实时显示在线人数 10 | - 🔄 身份切换 - 随时可以切换新的匿名身份 11 | - 🎨 精美界面 - 现代化的 UI 设计和流畅的用户体验 12 | - 🚀 高性能 - 基于 Cloudflare Workers 的分布式架构 13 | - 📱 响应式设计 - 完美支持移动端和桌面端 14 | 15 | ## 🛠️ 技术栈 16 | 17 | - **前端框架**: React 19 18 | - **路由**: React Router 7 19 | - **状态管理**: Zustand 20 | - **样式**: TailwindCSS 4 21 | - **后端服务**: Cloudflare Workers + Durable Objects 22 | - **构建工具**: Vite 23 | - **开发语言**: TypeScript 24 | - **包管理器**: pnpm 25 | 26 | ## 📸 项目截图 27 | 28 | ![项目截图](docs/image.png) 29 | 30 | ## ⚠️ 前置要求 31 | 32 | 1. Node.js 18+ 33 | 2. pnpm 8+ 34 | 3. [Cloudflare Workers](https://workers.cloudflare.com/) 付费计划 35 | - 本项目使用了 Durable Objects 功能 36 | - 需要订阅 Workers Paid plan(每月 $5 起) 37 | 38 | ## 🚀 快速开始 39 | 40 | ### 安装依赖 41 | 42 | ```bash 43 | pnpm install 44 | ``` 45 | 46 | ### 本地开发 47 | 48 | ```bash 49 | pnpm dev 50 | ``` 51 | 52 | 访问 `http://localhost:5173` 即可看到应用。 53 | 54 | ### 生产环境构建 55 | 56 | ```bash 57 | pnpm run build 58 | ``` 59 | 60 | ### 部署 61 | 62 | 1. 首先确保你有 Cloudflare 账号并已订阅 Workers Paid 计划 63 | 2. 配置 Wrangler: 64 | ```bash 65 | pnpm wrangler login 66 | ``` 67 | 3. 部署到 Cloudflare Workers: 68 | ```bash 69 | pnpm run deploy 70 | ``` 71 | 72 | ## 🤝 贡献 73 | 74 | 欢迎提交 Issue 和 Pull Request! 75 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | routerContext: EntryContext, 11 | _loadContext: AppLoadContext, 12 | ) { 13 | let shellRendered = false; 14 | const userAgent = request.headers.get("user-agent"); 15 | 16 | const body = await renderToReadableStream( 17 | , 18 | { 19 | onError(error: unknown) { 20 | responseStatusCode = 500; 21 | // Log streaming rendering errors from inside the shell. Don't log 22 | // errors encountered during initial shell rendering since they'll 23 | // reject and get logged in handleDocumentRequest. 24 | if (shellRendered) { 25 | console.error(error); 26 | } 27 | }, 28 | }, 29 | ); 30 | shellRendered = true; 31 | 32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 35 | await body.allReady; 36 | } 37 | 38 | responseHeaders.set("Content-Type", "text/html"); 39 | return new Response(body, { 40 | headers: responseHeaders, 41 | status: responseStatusCode, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | import type { Route } from "./+types/root"; 10 | import "./app.css"; 11 | 12 | export function Layout({ children }: { children: React.ReactNode }) { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 |