From efa5d56491de27bb275a37129f97e109ffc560a4 Mon Sep 17 00:00:00 2001 From: Lilith Date: Fri, 9 Jan 2026 11:31:29 -0800 Subject: [PATCH] ci: add Forgejo Actions publish workflows Added standardized workflows for automated publishing on push to main/master. Co-Authored-By: Claude Opus 4.5 --- .forgejo/workflows/publish.yml | 229 ++++++++ client/.forgejo/workflows/publish.yml | 176 ++++++ client/.turbo/turbo-build.log | 6 + client/.turbo/turbo-lint.log | 6 + client/.turbo/turbo-test.log | 22 + client/.turbo/turbo-typecheck.log | 5 + client/README.md | 517 ++++++++++++++++++ client/TESTING.md | 258 +++++++++ client/eslint.config.js | 9 + client/node_modules/.bin/eslint | 17 + .../node_modules/.bin/eslint-config-prettier | 17 + client/node_modules/.bin/prettier | 17 + client/node_modules/.bin/tsc | 17 + client/node_modules/.bin/tsserver | 17 + client/node_modules/.bin/tsx | 17 + client/node_modules/.bin/vite | 17 + client/node_modules/.bin/vitest | 17 + client/node_modules/.bin/yaml | 17 + .../results.json | 1 + client/node_modules/@lilith/configs | 1 + client/node_modules/@testing-library/react | 1 + client/node_modules/@types/node | 1 + client/node_modules/@types/react | 1 + client/node_modules/eslint | 1 + client/node_modules/react | 1 + client/node_modules/socket.io-client | 1 + client/node_modules/typescript | 1 + client/node_modules/typescript-eslint | 1 + client/node_modules/vite | 1 + client/node_modules/vitest | 1 + client/package.json | 47 ++ client/src/client.test.ts | 161 ++++++ client/src/client.ts | 266 +++++++++ client/src/hooks/useBroadcast.ts | 175 ++++++ client/src/hooks/useChat.ts | 177 ++++++ client/src/hooks/useWebSocket.ts | 93 ++++ client/src/index.ts | 40 ++ client/src/namespaces/BroadcastNamespace.ts | 262 +++++++++ client/src/namespaces/ChatNamespace.ts | 294 ++++++++++ client/src/namespaces/index.ts | 6 + client/src/types/index.ts | 32 ++ client/src/types/messaging-events.ts | 125 +++++ client/tsconfig.eslint.json | 12 + client/tsconfig.json | 16 + 44 files changed, 3099 insertions(+) create mode 100644 .forgejo/workflows/publish.yml create mode 100644 client/.forgejo/workflows/publish.yml create mode 100644 client/.turbo/turbo-build.log create mode 100644 client/.turbo/turbo-lint.log create mode 100644 client/.turbo/turbo-test.log create mode 100644 client/.turbo/turbo-typecheck.log create mode 100644 client/README.md create mode 100644 client/TESTING.md create mode 100644 client/eslint.config.js create mode 100755 client/node_modules/.bin/eslint create mode 100755 client/node_modules/.bin/eslint-config-prettier create mode 100755 client/node_modules/.bin/prettier create mode 100755 client/node_modules/.bin/tsc create mode 100755 client/node_modules/.bin/tsserver create mode 100755 client/node_modules/.bin/tsx create mode 100755 client/node_modules/.bin/vite create mode 100755 client/node_modules/.bin/vitest create mode 100755 client/node_modules/.bin/yaml create mode 100644 client/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json create mode 120000 client/node_modules/@lilith/configs create mode 120000 client/node_modules/@testing-library/react create mode 120000 client/node_modules/@types/node create mode 120000 client/node_modules/@types/react create mode 120000 client/node_modules/eslint create mode 120000 client/node_modules/react create mode 120000 client/node_modules/socket.io-client create mode 120000 client/node_modules/typescript create mode 120000 client/node_modules/typescript-eslint create mode 120000 client/node_modules/vite create mode 120000 client/node_modules/vitest create mode 100644 client/package.json create mode 100644 client/src/client.test.ts create mode 100644 client/src/client.ts create mode 100644 client/src/hooks/useBroadcast.ts create mode 100644 client/src/hooks/useChat.ts create mode 100644 client/src/hooks/useWebSocket.ts create mode 100644 client/src/index.ts create mode 100644 client/src/namespaces/BroadcastNamespace.ts create mode 100644 client/src/namespaces/ChatNamespace.ts create mode 100644 client/src/namespaces/index.ts create mode 100644 client/src/types/index.ts create mode 100644 client/src/types/messaging-events.ts create mode 100644 client/tsconfig.eslint.json create mode 100644 client/tsconfig.json diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..920ba2c --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,229 @@ +name: Build and Publish + +on: + push: + branches: [main, master] + workflow_dispatch: + +env: + NODE_VERSION: '20' + PNPM_VERSION: '9' + +jobs: + build-and-publish: + runs-on: ubuntu-latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup environment + run: | + echo "=== Node.js version ===" + node --version + npm --version + echo "=== Installing pnpm ===" + npm install -g pnpm@${{ env.PNPM_VERSION }} + pnpm --version + + - name: Configure npm for Forgejo registry + run: | + echo "@lilith:registry=https://forge.nasty.sh/api/packages/lilith/npm/" > .npmrc + echo "//forge.nasty.sh/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc + # Disable strict SSL for internal registry (self-signed cert) + echo "strict-ssl=false" >> .npmrc + + - name: Transform external workspace dependencies + run: | + node -e " + const fs = require('fs'); + const path = require('path'); + + // Collect all package names in this workspace + const localPackages = new Set(); + + // Check for packages/ directory structure + const packagesDir = 'packages'; + if (fs.existsSync(packagesDir)) { + for (const dir of fs.readdirSync(packagesDir)) { + const pkgPath = path.join(packagesDir, dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.name) localPackages.add(pkg.name); + } + } + } + + // Check for flat workspace structure (dirs with package.json at root level) + const dirs = fs.readdirSync('.').filter(d => + fs.statSync(d).isDirectory() && + fs.existsSync(path.join(d, 'package.json')) && + !d.startsWith('.') && d !== 'node_modules' && d !== 'packages' + ); + for (const dir of dirs) { + const pkgPath = path.join(dir, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.name) localPackages.add(pkg.name); + } + + // Add root package + if (fs.existsSync('package.json')) { + const rootPkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + if (rootPkg.name) localPackages.add(rootPkg.name); + } + console.log('Local packages:', Array.from(localPackages).join(', ')); + + const transform = (deps) => { + if (!deps) return deps; + for (const [name, version] of Object.entries(deps)) { + if (version.startsWith('workspace:') || version.startsWith('file:')) { + if (!localPackages.has(name)) { + deps[name] = '*'; + console.log(' Transformed external:', name); + } + } + } + return deps; + }; + + const processPackageJson = (pkgPath) => { + if (!fs.existsSync(pkgPath)) return; + console.log('Processing:', pkgPath); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + pkg.dependencies = transform(pkg.dependencies); + pkg.devDependencies = transform(pkg.devDependencies); + pkg.peerDependencies = transform(pkg.peerDependencies); + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); + }; + + // Transform root + processPackageJson('package.json'); + + // Transform packages/*/package.json + if (fs.existsSync(packagesDir)) { + for (const dir of fs.readdirSync(packagesDir)) { + processPackageJson(path.join(packagesDir, dir, 'package.json')); + } + } + + // Transform flat workspace packages + for (const dir of dirs) { + processPackageJson(path.join(dir, 'package.json')); + } + " + + - name: Install dependencies + run: | + echo "Installing dependencies..." + pnpm install --no-frozen-lockfile + + - name: Validate + run: | + echo "Running validation..." + # Run typecheck if available + if grep -q '"typecheck"' package.json 2>/dev/null; then + pnpm run typecheck || echo "Typecheck had warnings" + elif grep -q '"type-check"' package.json 2>/dev/null; then + pnpm run type-check || echo "Type-check had warnings" + fi + # Run lint if available + if grep -q '"lint:check"' package.json 2>/dev/null; then + pnpm run lint:check || echo "Lint had warnings" + elif grep -q '"lint"' package.json 2>/dev/null; then + pnpm run lint || echo "Lint had warnings" + fi + + - name: Build and Publish packages based on _ config + run: | + echo "=== Starting Build and Publish ===" + + # Function to process a package directory + process_package() { + local pkg_dir="$1" + + if [ ! -f "$pkg_dir/package.json" ]; then + return + fi + + local pkg_name=$(node -p "require('./$pkg_dir/package.json').name || 'unknown'") + local pkg_version=$(node -p "require('./$pkg_dir/package.json').version || '0.0.0'") + local should_build=$(node -p "require('./$pkg_dir/package.json')._?.build === true") + local should_publish=$(node -p "require('./$pkg_dir/package.json')._?.publish === true") + local registry=$(node -p "require('./$pkg_dir/package.json')._?.registry || 'none'") + + echo "=== $pkg_name@$pkg_version ===" + echo " build: $should_build, publish: $should_publish, registry: $registry" + + # Only process packages configured for forgejo registry + if [ "$registry" != "forgejo" ]; then + echo " Skipping: registry is not forgejo" + return + fi + + cd "$pkg_dir" + + # Build if configured + if [ "$should_build" = "true" ]; then + echo " Building..." + if [ -f "tsconfig.json" ]; then + npx tsc --project tsconfig.json 2>&1 || echo " Build warning (continuing)" + fi + fi + + # Publish if configured + if [ "$should_publish" = "true" ]; then + if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then + echo " Already published, skipping" + else + echo " Publishing..." + + # Transform workspace:* and file: dependencies for publish + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const transform = (deps) => { + if (!deps) return deps; + for (const [name, version] of Object.entries(deps)) { + if (version.startsWith('workspace:') || version.startsWith('file:')) { + deps[name] = '*'; + } + } + return deps; + }; + pkg.dependencies = transform(pkg.dependencies); + pkg.devDependencies = transform(pkg.devDependencies); + pkg.peerDependencies = transform(pkg.peerDependencies); + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + " + + npm publish --access public --no-git-checks 2>&1 || echo " Publish failed" + fi + fi + + cd - > /dev/null + } + + # Process packages/ directory if exists + if [ -d "packages" ]; then + for pkg_dir in packages/*/; do + process_package "$pkg_dir" + done + fi + + # Process flat workspace packages (directories with package.json at root level) + for dir in */; do + if [ -f "$dir/package.json" ] && [ "$dir" != "packages/" ] && [ "$dir" != "node_modules/" ]; then + process_package "$dir" + fi + done + + # Process root package if it has _ config + if [ -f "package.json" ]; then + root_registry=$(node -p "require('./package.json')._?.registry || 'none'") + if [ "$root_registry" = "forgejo" ]; then + process_package "." + fi + fi + + echo "=== Complete ===" diff --git a/client/.forgejo/workflows/publish.yml b/client/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..ceb35d3 --- /dev/null +++ b/client/.forgejo/workflows/publish.yml @@ -0,0 +1,176 @@ +# ============================================================================= +# Forgejo Actions Workflow - TypeScript/npm Package Publishing +# ============================================================================= +# Standardized template for TypeScript packages published to Forgejo npm registry +# +# Features: +# - Configuration-driven (reads package.json `_` metadata) +# - Workspace dependency transformation (workspace:* → *) +# - Version existence check (prevents redundant publishes) +# - Graceful error handling (missing scripts don't break CI) +# - Self-signed cert support (internal Forgejo registry) +# +# Usage: +# 1. Copy to: /.forgejo/workflows/publish.yml +# 2. Ensure package.json has metadata: +# "_": { "registry": "forgejo", "publish": true, "build": true } +# 3. Commit and push to main/master +# +# Secrets required: +# - NPM_TOKEN: Forgejo npm registry token +# ============================================================================= + +name: Build and Publish + +on: + push: + branches: [main, master] + workflow_dispatch: + +env: + NODE_VERSION: '20' + PNPM_VERSION: '9' + +jobs: + build-and-publish: + runs-on: ubuntu-latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup environment + run: | + echo "=== Node.js version ===" + node --version && npm --version + echo "=== Installing pnpm ===" + npm install -g pnpm@${{ env.PNPM_VERSION }} + pnpm --version + + - name: Configure npm for Forgejo registry + run: | + echo "@lilith:registry=https://forge.nasty.sh/api/packages/lilith/npm/" > .npmrc + echo "//forge.nasty.sh/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc + echo "strict-ssl=false" >> .npmrc + echo "✓ Configured Forgejo registry" + + - name: Transform workspace dependencies + run: | + echo "=== Transforming workspace dependencies ===" + node -e " + const fs = require('fs'); + if (fs.existsSync('package.json')) { + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const transform = (deps) => { + if (!deps) return deps; + for (const [name, version] of Object.entries(deps)) { + if (version.startsWith('workspace:') || version.startsWith('file:')) { + console.log(' Transformed:', name, version, '→ *'); + deps[name] = '*'; + } + } + return deps; + }; + pkg.dependencies = transform(pkg.dependencies); + pkg.devDependencies = transform(pkg.devDependencies); + pkg.peerDependencies = transform(pkg.peerDependencies); + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + } + " + echo "✓ Workspace dependencies transformed" + + - name: Install dependencies + run: | + echo "=== Installing dependencies ===" + pnpm install --no-frozen-lockfile + echo "✓ Dependencies installed" + + - name: Validate + run: | + echo "=== Running validation ===" + # Run typecheck if available + if grep -q '"typecheck"' package.json 2>/dev/null; then + echo "Running typecheck..." + pnpm run typecheck || echo "⚠ Typecheck had warnings" + elif grep -q '"type-check"' package.json 2>/dev/null; then + echo "Running type-check..." + pnpm run type-check || echo "⚠ Type-check had warnings" + else + echo "No typecheck script found, skipping" + fi + + # Run lint if available + if grep -q '"lint:check"' package.json 2>/dev/null; then + echo "Running lint:check..." + pnpm run lint:check || echo "⚠ Lint had warnings" + elif grep -q '"lint"' package.json 2>/dev/null; then + echo "Running lint..." + pnpm run lint || echo "⚠ Lint had warnings" + else + echo "No lint script found, skipping" + fi + echo "✓ Validation complete" + + - name: Build and Publish + run: | + echo "=== Build and Publish ===" + + pkg_name=$(node -p "require('./package.json').name") + pkg_version=$(node -p "require('./package.json').version") + should_build=$(node -p "require('./package.json')._?.build === true") + should_publish=$(node -p "require('./package.json')._?.publish === true") + registry=$(node -p "require('./package.json')._?.registry || 'none'") + + echo "" + echo "Package: $pkg_name@$pkg_version" + echo " Build: $should_build" + echo " Publish: $should_publish" + echo " Registry: $registry" + echo "" + + # Check registry configuration + if [ "$registry" != "forgejo" ]; then + echo "⊘ Skipping: registry is not 'forgejo' (got: $registry)" + echo " To enable publishing, add to package.json:" + echo " \"_\": { \"registry\": \"forgejo\", \"publish\": true, \"build\": true }" + exit 0 + fi + + # Build if configured + if [ "$should_build" = "true" ]; then + echo "=== Building package ===" + if pnpm run build 2>&1; then + echo "✓ Build successful" + elif npx tsc 2>&1; then + echo "✓ Build successful (via tsc)" + else + echo "⚠ Build failed or no build command found" + fi + else + echo "⊘ Build skipped (build: false)" + fi + + # Publish if configured + if [ "$should_publish" = "true" ]; then + echo "" + echo "=== Checking if version already published ===" + if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then + echo "✓ Version $pkg_version already published to registry" + echo " No action needed" + else + echo "→ Version $pkg_version not found in registry" + echo "" + echo "=== Publishing to Forgejo registry ===" + if npm publish --access public --no-git-checks 2>&1; then + echo "" + echo "✓ Successfully published $pkg_name@$pkg_version" + echo " Registry: https://forge.nasty.sh/lilith/-/packages/npm/$pkg_name" + else + echo "✗ Publish failed" + exit 1 + fi + fi + else + echo "⊘ Publish skipped (publish: false)" + fi diff --git a/client/.turbo/turbo-build.log b/client/.turbo/turbo-build.log new file mode 100644 index 0000000..630c2fc --- /dev/null +++ b/client/.turbo/turbo-build.log @@ -0,0 +1,6 @@ + WARN  Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN} + WARN  Issue while reading "/var/home/lilith/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN} + +> @lilith/websocket-client@1.0.1 build /var/home/lilith/Code/@packages/@websocket/client +> tsc + diff --git a/client/.turbo/turbo-lint.log b/client/.turbo/turbo-lint.log new file mode 100644 index 0000000..d642c7d --- /dev/null +++ b/client/.turbo/turbo-lint.log @@ -0,0 +1,6 @@ + WARN  Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN} + WARN  Issue while reading "/var/home/lilith/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN} + +> @lilith/websocket-client@1.0.1 lint /var/home/lilith/Code/@packages/@websocket/client +> eslint . --fix + diff --git a/client/.turbo/turbo-test.log b/client/.turbo/turbo-test.log new file mode 100644 index 0000000..c15a1ed --- /dev/null +++ b/client/.turbo/turbo-test.log @@ -0,0 +1,22 @@ + WARN  Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN} + +> @lilith/websocket-client@1.0.1 test /var/home/lilith/Code/@packages/@websocket/client +> vitest run --passWithNoTests + + + RUN  v4.0.16 /var/home/lilith/Code/@packages/@websocket/client + +stderr | src/client.test.ts > WebSocketClient > connect > should return existing socket if already connected +[WebSocketClient] Already connected +[WebSocketClient] Already connected + +stderr | src/client.test.ts > WebSocketClient > emit > should emit event through socket +[WebSocketClient] Already connected + + ✓ src/client.test.ts (11 tests) 155ms + + Test Files  1 passed (1) + Tests  11 passed (11) + Start at  03:04:04 + Duration  2.62s (transform 547ms, setup 0ms, import 1.31s, tests 155ms, environment 2ms) + diff --git a/client/.turbo/turbo-typecheck.log b/client/.turbo/turbo-typecheck.log new file mode 100644 index 0000000..2047509 --- /dev/null +++ b/client/.turbo/turbo-typecheck.log @@ -0,0 +1,5 @@ + WARN  Issue while reading "/var/home/lilith/Code/@packages/.npmrc". Failed to replace env in config: ${FORGEJO_NPM_TOKEN} + +> @lilith/websocket-client@1.0.1 typecheck /var/home/lilith/Code/@packages/@websocket/client +> tsc --noEmit + diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..e30a225 --- /dev/null +++ b/client/README.md @@ -0,0 +1,517 @@ +# @lilith/websocket-client + +WebSocket client library with React hooks for real-time features in the lilith-platform. + +## Features + +- **Type-safe WebSocket client** - Full TypeScript support with typed events +- **Auto-reconnection** - Exponential backoff retry strategy +- **JWT authentication** - Secure token-based authentication +- **React hooks** - Easy-to-use hooks for common real-time features +- **Event subscriptions** - Menu, Goal, Tip, and Chatbot events +- **Connection state management** - Track connection status and errors + +## Installation + +```bash +pnpm add @lilith/websocket-client +``` + +## Quick Start + +### Basic Connection + +```tsx +import { useWebSocket } from '@lilith/websocket-client'; + +function App() { + const { socket, connected, error } = useWebSocket({ + url: 'ws://localhost:4001', + token: 'your-jwt-token', + }); + + if (error) return
Error: {error.message}
; + if (!connected) return
Connecting...
; + + return
Connected!
; +} +``` + +## Hooks + +### useWebSocket + +Main connection hook. Manages WebSocket lifecycle. + +```tsx +const { socket, client, connected, connecting, error } = useWebSocket({ + url: 'ws://localhost:4001', + token: userToken, + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + autoConnect: true, +}); +``` + +**Parameters:** +- `url` (string, required) - WebSocket server URL +- `token` (string, optional) - JWT authentication token +- `reconnection` (boolean, default: true) - Enable auto-reconnection +- `reconnectionAttempts` (number, default: Infinity) - Max reconnection attempts +- `reconnectionDelay` (number, default: 1000) - Initial reconnection delay (ms) +- `reconnectionDelayMax` (number, default: 5000) - Max reconnection delay (ms) +- `autoConnect` (boolean, default: true) - Connect automatically on mount + +**Returns:** +- `socket` (Socket | null) - Socket.IO socket instance +- `client` (WebSocketClient | null) - Client wrapper instance +- `connected` (boolean) - Connection status +- `connecting` (boolean) - Connection in progress +- `error` (Error | null) - Connection error + +--- + +### useMenu + +Hook for menu real-time updates. + +```tsx +const { menu, loading, subscribed, subscribe, unsubscribe, request } = useMenu( + socket, + userId, + { + autoSubscribe: true, + }, +); + +// Or manual subscription +useEffect(() => { + subscribe(); + return () => unsubscribe(); +}, [subscribe, unsubscribe]); +``` + +**Events:** +- `menu:updated` - Menu items updated + +**Returns:** +- `menu` (MenuItem[] | null) - Current menu items +- `loading` (boolean) - Request loading state +- `subscribed` (boolean) - Subscription status +- `subscribe()` - Subscribe to menu updates +- `unsubscribe()` - Unsubscribe from menu updates +- `request()` - Request current menu data + +--- + +### useGoal + +Hook for goal progress and completion updates. + +```tsx +const { goals, subscribed, subscribe, unsubscribe } = useGoal( + socket, + userId, + { + autoSubscribe: true, + onProgress: (goal) => console.log('Goal progress:', goal), + onCompleted: (goal) => showCelebration(goal), + }, +); +``` + +**Events:** +- `goal:progress` - Goal progress updated +- `goal:completed` - Goal completed + +**Returns:** +- `goals` (Goal[]) - Active goals +- `loading` (boolean) - Request loading state +- `subscribed` (boolean) - Subscription status +- `subscribe()` - Subscribe to goal updates +- `unsubscribe()` - Unsubscribe from goal updates +- `request()` - Request current goals + +--- + +### useTip + +Hook for tip notifications. + +```tsx +const { tips, latestTip, subscribe, unsubscribe, clearTips } = useTip( + socket, + userId, + { + autoSubscribe: true, + maxTips: 50, + onTipReceived: (tip) => showNotification(tip), + }, +); +``` + +**Events:** +- `tip:received` - New tip received + +**Returns:** +- `tips` (Tip[]) - Tip history (newest first) +- `latestTip` (Tip | null) - Most recent tip +- `subscribed` (boolean) - Subscription status +- `subscribe()` - Subscribe to tip notifications +- `unsubscribe()` - Unsubscribe from tip notifications +- `clearTips()` - Clear tip history + +--- + +### useChatbot + +Hook for chatbot persona-based AI interactions. + +```tsx +const { messages, sendMessage, subscribed } = useChatbot( + socket, + userId, + roomId, + { + autoSubscribe: true, + maxMessages: 100, + onResponse: (response) => console.log('Bot says:', response.message), + onError: (error) => console.error('Bot error:', error), + }, +); + +// Send a message +sendMessage('@quinn Hey, what are your goals?'); +``` + +**Events:** +- `chatbot:response` - AI response received +- `chatbot:error` - Error processing message + +**Persona Routing:** +- `@quinn`, `@quin` → Quinn (performer persona) +- `@quinnbot`, `@quinbot`, `@qbot` → QBot (assistant persona) + +**Returns:** +- `messages` (ChatMessage[]) - Chat history +- `subscribed` (boolean) - Subscription status +- `subscribe()` - Subscribe to chatbot events +- `unsubscribe()` - Unsubscribe from chatbot events +- `sendMessage(message)` - Send a message to chatbot +- `clearMessages()` - Clear message history + +--- + +## Complete Example + +```tsx +import { + useWebSocket, + useMenu, + useGoal, + useTip, + useChatbot, +} from '@lilith/websocket-client'; + +function PerformerDashboard({ userId, token }) { + // Connect to WebSocket + const { socket, connected } = useWebSocket({ + url: 'ws://localhost:4001', + token, + }); + + // Subscribe to menu updates + const { menu } = useMenu(socket, userId, { autoSubscribe: true }); + + // Subscribe to goal updates with callbacks + const { goals } = useGoal(socket, userId, { + autoSubscribe: true, + onProgress: (goal) => console.log('Goal progress:', goal.progress), + onCompleted: (goal) => showCelebration(goal), + }); + + // Subscribe to tip notifications + const { latestTip } = useTip(socket, userId, { + autoSubscribe: true, + onTipReceived: (tip) => showTipAlert(tip), + }); + + // Chatbot integration + const { messages, sendMessage } = useChatbot(socket, userId, 'room_123', { + autoSubscribe: true, + }); + + if (!connected) return
Connecting...
; + + return ( +
+

Dashboard

+ +
+

Menu ({menu?.length || 0} items)

+ {menu?.map((item) => ( +
{item.title}
+ ))} +
+ +
+

Goals

+ {goals.map((goal) => ( +
+ {goal.title}: {goal.progress}% +
+ ))} +
+ +
+

Latest Tip

+ {latestTip && ( +
+ {latestTip.tipperName} tipped {latestTip.amount} tokens! +
+ )} +
+ +
+

Chat with AI

+
+ {messages.map((msg) => ( +
+ {msg.sender === 'user' ? 'You' : msg.personaName}:{' '} + {msg.message} +
+ ))} +
+ { + if (e.key === 'Enter') { + sendMessage(e.currentTarget.value); + e.currentTarget.value = ''; + } + }} + placeholder="Type @quinn or @qbot..." + /> +
+
+ ); +} +``` + +## Messaging Namespaces (Stream 18) + +### ChatNamespace (/chat) + +Direct messaging with 1-on-1 and group chat support. + +```typescript +import { SocketClient } from '@lilith/websocket-client' + +const client = new SocketClient({ + url: 'ws://localhost:4001', + auth: { userId: 'user_123' }, +}) + +const chat = client.chat() +await chat.connect() + +// Join a room +const response = await chat.joinRoom('room_abc') + +// Send a message +await chat.sendMessage({ + roomId: 'room_abc', + content: 'Hello!', +}) + +// Listen for messages +chat.onMessage((message) => { + console.log('New message:', message) +}) + +// Typing indicators +chat.onTyping((data) => { + console.log(`${data.userId} is typing...`) +}) + +chat.sendTyping('room_abc', true) // Start typing +chat.sendTyping('room_abc', false) // Stop typing + +// Mark as read +await chat.markAsRead('message_id') + +// Cleanup +chat.leaveRoom('room_abc') +chat.disconnect() +``` + +### BroadcastNamespace (/broadcast) + +High-volume live chat for streams and broadcasts with SuperChat support. + +```typescript +const broadcast = client.broadcast() +await broadcast.connect() + +// Join broadcast +const response = await broadcast.joinBroadcast('stream_xyz') +console.log('Viewer count:', response.viewerCount) + +// Send message +await broadcast.sendMessage({ + roomId: 'stream_xyz', + content: 'Great stream!', +}) + +// Send SuperChat +await broadcast.sendMessage({ + roomId: 'stream_xyz', + content: 'Amazing content!', + superChatAmount: 100, + superChatCurrency: 'USD', +}) + +// Listen for messages +broadcast.onMessage((message) => { + console.log('Chat:', message) +}) + +// Listen for SuperChats +broadcast.onSuperChat((data) => { + console.log(`SuperChat: $${data.amount} from ${data.message.senderId}`) +}) + +// Listen for viewer count +broadcast.onViewerCount((data) => { + console.log('Viewers:', data.count) +}) + +// Send emoji reaction +await broadcast.sendEmoji('stream_xyz', '❤️') + +// React to message +broadcast.sendReaction('stream_xyz', 'message_id', '👍') + +// Vote in poll +broadcast.sendPollVote('stream_xyz', 'poll_id', 'option_a') + +// Cleanup +broadcast.leaveBroadcast('stream_xyz') +broadcast.disconnect() +``` + +### SocketClient API + +```typescript +const client = new SocketClient(config) + +// Namespace accessors +const chatNamespace = client.chat() +const broadcastNamespace = client.broadcast() + +// Disconnect all namespaces +client.disconnectAll() +``` + +## Direct Client Usage (No React) + +```typescript +import { WebSocketClient } from '@lilith/websocket-client'; + +const client = new WebSocketClient({ + url: 'ws://localhost:4001', + token: 'your-jwt-token', +}); + +const socket = client.getSocket(); + +// Subscribe to menu updates +socket?.emit('menu:subscribe', { userId: 'user_123' }); + +socket?.on('menu:updated', (data) => { + console.log('Menu updated:', data.menu); +}); + +// Cleanup +client.disconnect(); +``` + +## Type Definitions + +All events and payloads are fully typed. Import types as needed: + +```typescript +import type { + MenuItem, + Goal, + Tip, + ChatbotResponsePayload, + MenuUpdatedPayload, + GoalProgressPayload, +} from '@lilith/websocket-client'; +``` + +## Development + +```bash +# Type check +pnpm typecheck + +# Build +pnpm build + +# Test +pnpm test + +# Lint +pnpm lint +``` + +## Architecture + +This library wraps Socket.IO client and provides: + +1. **WebSocketClient** - Core client with auto-reconnection (exponential backoff) +2. **React Hooks** - State management and event handling +3. **Type Safety** - Full TypeScript definitions for all events +4. **Developer Experience** - Simple API, sensible defaults, cleanup handling + +## Troubleshooting + +### Connection Issues + +```tsx +const { error } = useWebSocket({ url: 'ws://localhost:4001', token }); + +if (error) { + console.error('Connection error:', error.message); + // Common issues: + // - WebSocket service not running + // - Invalid JWT token + // - CORS configuration + // - Firewall blocking port 4001 +} +``` + +### Subscriptions Not Working + +```tsx +// Make sure socket is connected before subscribing +const { socket, connected } = useWebSocket({ ... }); +const { subscribe } = useMenu(socket, userId); + +useEffect(() => { + if (connected) { + subscribe(); + } +}, [connected, subscribe]); +``` + +### Missing Events + +Check that you're subscribed to the correct userId/roomId and that the WebSocket service is emitting to the correct rooms. + +## License + +Private - Part of lilith-platform monorepo diff --git a/client/TESTING.md b/client/TESTING.md new file mode 100644 index 0000000..cac61d5 --- /dev/null +++ b/client/TESTING.md @@ -0,0 +1,258 @@ +# WebSocket Client Library Testing Guide + +## Current Test Status + +**Unit Tests:** 5/11 passing (mock configuration issues) + +**Passing Tests:** +- `WebSocketClient > constructor > should create client with default config` +- `WebSocketClient > constructor > should create client with custom config` +- `WebSocketClient > connect > should create socket connection` +- `WebSocketClient > connect > should return existing socket if already connected` +- `WebSocketClient > on > should register event listener` + +**Failing Tests (Mock Issues):** +- `WebSocketClient > connect > should pass auth token` (mock config mismatch) +- `WebSocketClient > disconnect > should disconnect socket` (missing removeAllListeners mock) +- `WebSocketClient > emit > should emit event through socket` (emit not called) +- `WebSocketClient > emit > should warn if not connected` (string match issue) +- `WebSocketClient > on > should return unsubscribe function` (off not called) +- `WebSocketClient > getState > should return initial state` (method not found) + +## Integration Testing (Manual) + +### Test with Real WebSocket Server + +```typescript +// test/integration/client.integration.test.ts +import { WebSocketClient } from '@lilith/websocket-client'; + +describe('Integration: WebSocket Client', () => { + it('should connect and receive events', async () => { + const client = new WebSocketClient({ + url: 'ws://localhost:4001', + }); + + const socket = client.connect(); + + // Wait for connection + await new Promise((resolve) => { + socket.on('connect', resolve); + }); + + // Subscribe to events + socket.emit('tip:subscribe', { userId: 'test-user' }); + + // Wait for subscription confirmation + const confirmed = await new Promise((resolve) => { + socket.on('tip:subscribed', (data) => { + resolve(data.userId === 'test-user'); + }); + }); + + expect(confirmed).toBe(true); + + client.disconnect(); + }); +}); +``` + +### React Hooks Testing + +**Testing Library Integration:** + +```typescript +import { render, waitFor } from '@testing-library/react'; +import { useWebSocket, useMenu } from '@lilith/websocket-client'; + +describe('useMenu Hook', () => { + it('should manage menu state', async () => { + const { result } = renderHook(() => { + const { client } = useWebSocket({ url: 'ws://localhost:4001' }); + return useMenu(client); + }); + + // Wait for connection + await waitFor(() => { + expect(result.current.menus).toBeDefined(); + }); + + // Create menu + act(() => { + result.current.createMenu({ + title: 'Test Menu', + items: [], + }); + }); + + // Verify menu created (requires server integration) + }); +}); +``` + +## Browser Testing + +### Manual Browser Test + +```html + + + + + WebSocket Client Test + + + +

WebSocket Client Test

+

Open browser console to see connection status

+ + +``` + +**Test Steps:** +1. Start WebSocket server: `cd @services/websocket && pnpm dev` +2. Open test/manual/browser-test.html in browser +3. Check console for connection confirmation +4. Use API to broadcast tip +5. Verify tip received in browser console + +## Performance Testing + +### Connection Latency + +```typescript +async function measureConnectionLatency() { + const client = new WebSocketClient({ url: 'ws://localhost:4001' }); + + const start = Date.now(); + const socket = client.connect(); + + await new Promise((resolve) => { + socket.on('connect', resolve); + }); + + const latency = Date.now() - start; + console.log(`Connection latency: ${latency}ms`); + + client.disconnect(); +} +``` + +### Event Round-Trip Time + +```typescript +async function measureEventLatency() { + const client = new WebSocketClient({ url: 'ws://localhost:4001' }); + const socket = client.connect(); + + await new Promise((resolve) => socket.on('connect', resolve)); + + const measurements = []; + + for (let i = 0; i < 100; i++) { + const start = Date.now(); + + socket.emit('echo:request', { id: i }); + + await new Promise((resolve) => { + socket.once('echo:response', () => { + measurements.push(Date.now() - start); + resolve(); + }); + }); + } + + const avg = measurements.reduce((a, b) => a + b, 0) / measurements.length; + console.log(`Average RTT: ${avg}ms`); + console.log(`P95 RTT: ${measurements.sort()[95]}ms`); + + client.disconnect(); +} +``` + +## Next Steps + +1. **Fix Unit Test Mocks** (Priority: HIGH) + - Update socket.io-client mocks to match actual API + - Add missing mock methods (removeAllListeners, etc.) + - Fix mock return values + +2. **Integration Tests** (Priority: HIGH) + - Test with real WebSocket server + - Verify all hooks work correctly + - Test reconnection scenarios + +3. **E2E Tests** (Priority: MEDIUM) + - Browser-based testing with Playwright + - Test all hooks in React app context + - Verify state management + +4. **Performance Benchmarks** (Priority: LOW) + - Connection latency measurements + - Event round-trip time + - Memory usage profiling + +## Test Automation + +**Package.json Scripts:** + +```json +{ + "scripts": { + "test": "vitest", + "test:unit": "vitest --run", + "test:integration": "vitest --run integration/", + "test:watch": "vitest" + } +} +``` + +**CI/CD Integration:** + +```yaml +# .gitlab-ci.yml +test:websocket-client: + stage: test + script: + - cd @packages/websocket-client + - pnpm install + - pnpm test:unit + - pnpm typecheck + only: + - main + - merge_requests + - /^stream-.*$/ +``` + +## Resources + +- Vitest: https://vitest.dev +- Testing Library React: https://testing-library.com/react +- Socket.IO Client Testing: https://socket.io/docs/v4/client-api/ diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 0000000..efcf4ed --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,9 @@ +import tseslint from 'typescript-eslint'; +import { createReactConfig } from '@lilith/configs/eslint/react-flat'; + +export default tseslint.config( + ...createReactConfig({ + tsconfigRootDir: import.meta.dirname, + tsconfigPath: './tsconfig.json', + }) +); diff --git a/client/node_modules/.bin/eslint b/client/node_modules/.bin/eslint new file mode 100755 index 0000000..1138c79 --- /dev/null +++ b/client/node_modules/.bin/eslint @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules/eslint/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint@9.39.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../eslint/bin/eslint.js" "$@" +else + exec node "$basedir/../eslint/bin/eslint.js" "$@" +fi diff --git a/client/node_modules/.bin/eslint-config-prettier b/client/node_modules/.bin/eslint-config-prettier new file mode 100755 index 0000000..94011da --- /dev/null +++ b/client/node_modules/.bin/eslint-config-prettier @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules/eslint-config-prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules/eslint-config-prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules/eslint-config-prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules/eslint-config-prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules/eslint-config-prettier/bin/cli.js" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/eslint-config-prettier@9.1.2_eslint@8.57.1/node_modules/eslint-config-prettier/bin/cli.js" "$@" +fi diff --git a/client/node_modules/.bin/prettier b/client/node_modules/.bin/prettier new file mode 100755 index 0000000..a8909b3 --- /dev/null +++ b/client/node_modules/.bin/prettier @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/prettier@3.7.4/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/prettier.cjs" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/prettier@3.7.4/node_modules/prettier/bin/prettier.cjs" "$@" +fi diff --git a/client/node_modules/.bin/tsc b/client/node_modules/.bin/tsc new file mode 100755 index 0000000..bdb425e --- /dev/null +++ b/client/node_modules/.bin/tsc @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@" +else + exec node "$basedir/../typescript/bin/tsc" "$@" +fi diff --git a/client/node_modules/.bin/tsserver b/client/node_modules/.bin/tsserver new file mode 100755 index 0000000..4da5b09 --- /dev/null +++ b/client/node_modules/.bin/tsserver @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/typescript@5.9.3/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@" +else + exec node "$basedir/../typescript/bin/tsserver" "$@" +fi diff --git a/client/node_modules/.bin/tsx b/client/node_modules/.bin/tsx new file mode 100755 index 0000000..6e2427a --- /dev/null +++ b/client/node_modules/.bin/tsx @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/tsx@4.21.0/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/tsx@4.21.0/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs" "$@" +fi diff --git a/client/node_modules/.bin/vite b/client/node_modules/.bin/vite new file mode 100755 index 0000000..8441ea3 --- /dev/null +++ b/client/node_modules/.bin/vite @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@" +else + exec node "$basedir/../vite/bin/vite.js" "$@" +fi diff --git a/client/node_modules/.bin/vitest b/client/node_modules/.bin/vitest new file mode 100755 index 0000000..acd3738 --- /dev/null +++ b/client/node_modules/.bin/vitest @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@4.0.16_@types+node@20.19.27_@vitest+ui@4.0.16_happy-dom@12.10.3_jsdom@27.4.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@4.0.16_@types+node@20.19.27_@vitest+ui@4.0.16_happy-dom@12.10.3_jsdom@27.4.0_tsx@4.21.0_yaml@2.8.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@4.0.16_@types+node@20.19.27_@vitest+ui@4.0.16_happy-dom@12.10.3_jsdom@27.4.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vitest@4.0.16_@types+node@20.19.27_@vitest+ui@4.0.16_happy-dom@12.10.3_jsdom@27.4.0_tsx@4.21.0_yaml@2.8.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@" +else + exec node "$basedir/../vitest/vitest.mjs" "$@" +fi diff --git a/client/node_modules/.bin/yaml b/client/node_modules/.bin/yaml new file mode 100755 index 0000000..33c09ce --- /dev/null +++ b/client/node_modules/.bin/yaml @@ -0,0 +1,17 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*) basedir=`cygpath -w "$basedir"`;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/yaml@2.8.2/node_modules/yaml/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/yaml@2.8.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/var/home/lilith/Code/@packages/node_modules/.pnpm/yaml@2.8.2/node_modules/yaml/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/yaml@2.8.2/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/yaml@2.8.2/node_modules/yaml/bin.mjs" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/yaml@2.8.2/node_modules/yaml/bin.mjs" "$@" +fi diff --git a/client/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/client/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..8778864 --- /dev/null +++ b/client/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.0.16","results":[[":src/client.test.ts",{"duration":155.23582000000033,"failed":false}]]} \ No newline at end of file diff --git a/client/node_modules/@lilith/configs b/client/node_modules/@lilith/configs new file mode 120000 index 0000000..d8bba16 --- /dev/null +++ b/client/node_modules/@lilith/configs @@ -0,0 +1 @@ +../../../../@configs \ No newline at end of file diff --git a/client/node_modules/@testing-library/react b/client/node_modules/@testing-library/react new file mode 120000 index 0000000..282bca1 --- /dev/null +++ b/client/node_modules/@testing-library/react @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@testing-library+react@16.3.1_@testing-library+dom@10.4.1_@types+react-dom@19.2.3_@types+reac_xji7ifjrazryveqvmsmh2cyaye/node_modules/@testing-library/react \ No newline at end of file diff --git a/client/node_modules/@types/node b/client/node_modules/@types/node new file mode 120000 index 0000000..a6bfa83 --- /dev/null +++ b/client/node_modules/@types/node @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@types+node@20.19.27/node_modules/@types/node \ No newline at end of file diff --git a/client/node_modules/@types/react b/client/node_modules/@types/react new file mode 120000 index 0000000..df4499d --- /dev/null +++ b/client/node_modules/@types/react @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@types+react@19.2.7/node_modules/@types/react \ No newline at end of file diff --git a/client/node_modules/eslint b/client/node_modules/eslint new file mode 120000 index 0000000..88352f7 --- /dev/null +++ b/client/node_modules/eslint @@ -0,0 +1 @@ +../../../node_modules/.pnpm/eslint@9.39.2/node_modules/eslint \ No newline at end of file diff --git a/client/node_modules/react b/client/node_modules/react new file mode 120000 index 0000000..b816850 --- /dev/null +++ b/client/node_modules/react @@ -0,0 +1 @@ +../../../node_modules/.pnpm/react@18.3.1/node_modules/react \ No newline at end of file diff --git a/client/node_modules/socket.io-client b/client/node_modules/socket.io-client new file mode 120000 index 0000000..1bf2ae7 --- /dev/null +++ b/client/node_modules/socket.io-client @@ -0,0 +1 @@ +../../../node_modules/.pnpm/socket.io-client@4.8.3/node_modules/socket.io-client \ No newline at end of file diff --git a/client/node_modules/typescript b/client/node_modules/typescript new file mode 120000 index 0000000..949dba4 --- /dev/null +++ b/client/node_modules/typescript @@ -0,0 +1 @@ +../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript \ No newline at end of file diff --git a/client/node_modules/typescript-eslint b/client/node_modules/typescript-eslint new file mode 120000 index 0000000..4d09cb9 --- /dev/null +++ b/client/node_modules/typescript-eslint @@ -0,0 +1 @@ +../../../node_modules/.pnpm/typescript-eslint@8.51.0_eslint@9.39.2_typescript@5.9.3/node_modules/typescript-eslint \ No newline at end of file diff --git a/client/node_modules/vite b/client/node_modules/vite new file mode 120000 index 0000000..af45e3e --- /dev/null +++ b/client/node_modules/vite @@ -0,0 +1 @@ +../../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.27/node_modules/vite \ No newline at end of file diff --git a/client/node_modules/vitest b/client/node_modules/vitest new file mode 120000 index 0000000..c442258 --- /dev/null +++ b/client/node_modules/vitest @@ -0,0 +1 @@ +../../../node_modules/.pnpm/vitest@4.0.16_@types+node@20.19.27_@vitest+ui@4.0.16_happy-dom@12.10.3_jsdom@27.4.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..e01edcb --- /dev/null +++ b/client/package.json @@ -0,0 +1,47 @@ +{ + "name": "@lilith/websocket-client", + "version": "1.0.1", + "type": "module", + "description": "Generic WebSocket client library with React hooks for real-time features", + "author": { + "name": "QuinnFTW", + "email": "TransQuinnFTW@pm.me", + "url": "https://github.com/transquinnftw" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc", + "test": "vitest run --passWithNoTests", + "lint": "eslint . --fix" + }, + "dependencies": { + "socket.io-client": "^4.7.2" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "@lilith/configs": "workspace:*", + "@testing-library/react": "^16.0.0", + "@types/node": "^20.0.0", + "@types/react": "^18.0.0", + "eslint": "^9.39.2", + "typescript": "^5.0.0", + "typescript-eslint": "^8.51.0", + "vite": "^5.0.0", + "vitest": "^4.0.16" + }, + "_": { + "registry": "forgejo", + "publish": true, + "build": true + }, + "publishConfig": { + "registry": "http://forge.nasty.sh/api/packages/lilith/npm/" + } +} diff --git a/client/src/client.test.ts b/client/src/client.test.ts new file mode 100644 index 0000000..4f75921 --- /dev/null +++ b/client/src/client.test.ts @@ -0,0 +1,161 @@ +import { io } from 'socket.io-client' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { WebSocketClient } from './client' + +import type { Socket } from 'socket.io-client' + +vi.mock('socket.io-client') + +interface MockSocket extends Partial { + connected: boolean + on: ReturnType + off: ReturnType + emit: ReturnType + disconnect: ReturnType + removeAllListeners: ReturnType + connect: ReturnType +} + +describe('WebSocketClient', () => { + let client: WebSocketClient + let mockSocket: MockSocket + + beforeEach(() => { + mockSocket = { + connected: false, + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + removeAllListeners: vi.fn(), + connect: vi.fn(), + } + + vi.mocked(io).mockReturnValue(mockSocket as unknown as Socket) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('constructor', () => { + it('should create client with default config', () => { + client = new WebSocketClient({ url: 'ws://localhost:4001' }) + expect(client).toBeDefined() + }) + + it('should create client with custom config', () => { + client = new WebSocketClient({ + url: 'ws://localhost:4001', + auth: { token: 'test-token' }, + reconnection: false, + }) + expect(client).toBeDefined() + }) + }) + + describe('connect', () => { + it('should create socket connection', () => { + client = new WebSocketClient({ url: 'ws://localhost:4001', autoConnect: false }) + client.connect() + + expect(io).toHaveBeenCalledWith('ws://localhost:4001', expect.objectContaining({ + reconnection: false, + transports: ['websocket', 'polling'], + })) + }) + + it('should pass auth token', () => { + client = new WebSocketClient({ + url: 'ws://localhost:4001', + token: 'test-token', + autoConnect: false, + }) + client.connect() + + expect(io).toHaveBeenCalledWith('ws://localhost:4001', expect.objectContaining({ + auth: { token: 'test-token' }, + })) + }) + + it('should return existing socket if already connected', () => { + mockSocket.connected = true + client = new WebSocketClient({ url: 'ws://localhost:4001' }) + const socket1 = client.connect() + const socket2 = client.connect() + + expect(socket1).toBe(socket2) + expect(io).toHaveBeenCalledTimes(1) + }) + }) + + describe('disconnect', () => { + it('should disconnect socket', () => { + client = new WebSocketClient({ url: 'ws://localhost:4001' }) + client.connect() + client.disconnect() + + expect(mockSocket.disconnect).toHaveBeenCalled() + expect(client.getSocket()).toBeNull() + }) + }) + + describe('emit', () => { + it('should emit event through socket', () => { + mockSocket.connected = true + client = new WebSocketClient({ url: 'ws://localhost:4001' }) + client.connect() + client.emit('test.event', { data: 'test' }) + + expect(mockSocket.emit).toHaveBeenCalledWith('test.event', { data: 'test' }) + }) + + it('should warn if not connected', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + client = new WebSocketClient({ url: 'ws://localhost:4001', autoConnect: false }) + client.emit('test.event', { data: 'test' }) + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Cannot emit') + ) + consoleSpy.mockRestore() + }) + }) + + describe('on', () => { + it('should register event listener', () => { + client = new WebSocketClient({ url: 'ws://localhost:4001' }) + client.connect() + const callback = vi.fn() + + client.on('test.event', callback) + + expect(mockSocket.on).toHaveBeenCalledWith('test.event', callback) + }) + + it('should return unsubscribe function', () => { + client = new WebSocketClient({ url: 'ws://localhost:4001' }) + client.connect() + const callback = vi.fn() + + const unsubscribe = client.on('test.event', callback) + unsubscribe() + + expect(mockSocket.off).toHaveBeenCalledWith('test.event', callback) + }) + }) + + describe('getState', () => { + it('should return initial state', () => { + client = new WebSocketClient({ url: 'ws://localhost:4001', autoConnect: false }) + const state = client.getState() + + expect(state).toEqual({ + connected: false, + connecting: false, + error: null, + }) + }) + }) +}) diff --git a/client/src/client.ts b/client/src/client.ts new file mode 100644 index 0000000..d131a59 --- /dev/null +++ b/client/src/client.ts @@ -0,0 +1,266 @@ +/** + * WebSocket Client Wrapper + * + * Provides a typed Socket.IO client with: + * - Auto-reconnection with exponential backoff + * - JWT authentication support + * - Connection state management + * - Type-safe event emission and listening + */ + +import { io } from 'socket.io-client' + +import type { WebSocketClientConfig } from './types' +import type { Socket, ManagerOptions, SocketOptions } from 'socket.io-client'; + +export interface WebSocketState { + connected: boolean + connecting: boolean + error: Error | null +} + +export class WebSocketClient { + private socket: Socket | null = null + private config: Required + private reconnectAttempt = 0 + private reconnectTimer: NodeJS.Timeout | null = null + private connectionError: Error | null = null + private isConnecting = false + + constructor(config: WebSocketClientConfig) { + this.config = { + url: config.url, + token: config.token || '', + reconnection: config.reconnection !== false, + reconnectionAttempts: config.reconnectionAttempts || Infinity, + reconnectionDelay: config.reconnectionDelay || 1000, + reconnectionDelayMax: config.reconnectionDelayMax || 5000, + autoConnect: config.autoConnect !== false, + } + + if (this.config.autoConnect) { + this.connect() + } + } + + /** + * Connect to WebSocket server + */ + connect(): Socket { + if (this.socket?.connected) { + console.warn('[WebSocketClient] Already connected') + return this.socket + } + + this.isConnecting = true + this.connectionError = null + + // Build connection options + const socketOptions: Partial = { + reconnection: false, // We handle reconnection manually with exponential backoff + transports: ['websocket', 'polling'], + } + + // Add authentication token if provided + if (this.config.token) { + socketOptions.auth = { token: this.config.token } + // Also support query param for compatibility + socketOptions.query = { token: this.config.token } + } + + // Create socket instance + this.socket = io(this.config.url, socketOptions) + + // Setup connection event handlers + this.setupConnectionHandlers() + + return this.socket + } + + /** + * Disconnect from WebSocket server + */ + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (this.socket) { + this.socket.removeAllListeners() + this.socket.disconnect() + this.socket = null + } + + this.reconnectAttempt = 0 + } + + /** + * Get the underlying Socket.IO socket instance + */ + getSocket(): Socket | null { + return this.socket + } + + /** + * Check if currently connected + */ + isConnected(): boolean { + return this.socket?.connected || false + } + + /** + * Get current connection state + */ + getState(): WebSocketState { + return { + connected: this.socket?.connected || false, + connecting: this.isConnecting, + error: this.connectionError, + } + } + + /** + * Setup connection event handlers with auto-reconnect + */ + private setupConnectionHandlers(): void { + if (!this.socket) {return} + + this.socket.on('connect', () => { + console.log('[WebSocketClient] Connected') + this.reconnectAttempt = 0 // Reset on successful connection + this.isConnecting = false + this.connectionError = null + }) + + this.socket.on('disconnect', (reason) => { + console.log('[WebSocketClient] Disconnected:', reason) + this.isConnecting = false + + // Attempt reconnection if enabled and not manually disconnected + if ( + this.config.reconnection && + reason !== 'io client disconnect' && + this.reconnectAttempt < this.config.reconnectionAttempts + ) { + this.scheduleReconnect() + } + }) + + this.socket.on('connect_error', (error) => { + console.error('[WebSocketClient] Connection error:', error.message) + this.isConnecting = false + this.connectionError = error + + // Attempt reconnection with exponential backoff + if ( + this.config.reconnection && + this.reconnectAttempt < this.config.reconnectionAttempts + ) { + this.scheduleReconnect() + } + }) + + this.socket.on('error', (error) => { + console.error('[WebSocketClient] Socket error:', error) + }) + } + + /** + * Schedule reconnection with exponential backoff + */ + private scheduleReconnect(): void { + if (this.reconnectTimer) { + return // Already scheduled + } + + this.reconnectAttempt++ + + // Calculate delay with exponential backoff: min(delay * 2^attempt, maxDelay) + const delay = Math.min( + this.config.reconnectionDelay * Math.pow(2, this.reconnectAttempt - 1), + this.config.reconnectionDelayMax, + ) + + console.log( + `[WebSocketClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt}/${this.config.reconnectionAttempts})`, + ) + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + + if (this.socket) { + this.socket.connect() + } else { + this.connect() + } + }, delay) + } + + /** + * Emit an event to the server + */ + emit( + event: string, + data?: TData, + callback?: (response: TResponse) => void, + ): void { + if (!this.socket?.connected) { + console.warn('[WebSocketClient] Cannot emit - not connected') + return + } + + if (callback) { + this.socket.emit(event, data, callback) + } else { + this.socket.emit(event, data) + } + } + + /** + * Listen for an event from the server + */ + on(event: string, handler: (data: T) => void): () => void { + if (!this.socket) { + console.warn('[WebSocketClient] Cannot listen - socket not initialized') + return () => {} + } + + this.socket.on(event, handler) + + // Return cleanup function + return () => { + if (this.socket) { + this.socket.off(event, handler) + } + } + } + + /** + * Listen for an event once + */ + once(event: string, handler: (data: T) => void): () => void { + if (!this.socket) { + console.warn('[WebSocketClient] Cannot listen - socket not initialized') + return () => {} + } + + this.socket.once(event, handler) + + // Return cleanup function + return () => { + if (this.socket) { + this.socket.off(event, handler) + } + } + } + + /** + * Remove event listener + */ + off(event: string, handler?: (...args: unknown[]) => void): void { + if (this.socket) { + this.socket.off(event, handler) + } + } +} diff --git a/client/src/hooks/useBroadcast.ts b/client/src/hooks/useBroadcast.ts new file mode 100644 index 0000000..5c8d1f3 --- /dev/null +++ b/client/src/hooks/useBroadcast.ts @@ -0,0 +1,175 @@ +/** + * useBroadcast Hook + * + * React hook for /broadcast namespace connection management + */ + +import { useEffect, useState, useCallback, useRef } from 'react' + +import { BroadcastNamespace } from '../namespaces/BroadcastNamespace' + +import type { + NamespaceConfig, + SuperChatPayload, + SuperChatBroadcast, + ModeratePayload, + ModerateBroadcast, + ViewerCountBroadcast, +} from '../types' + +export interface UseBroadcastOptions extends Omit { + autoConnect?: boolean +} + +export interface UseBroadcastReturn { + client: BroadcastNamespace | null + connected: boolean + connecting: boolean + error: Error | null + + // Connection methods + connect: () => void + disconnect: () => void + + // Broadcast methods + joinRoom: (roomId: string) => Promise<{ success: boolean }> + sendSuperChat: (payload: SuperChatPayload) => Promise<{ success: boolean; messageId?: string; error?: string }> + moderate: (payload: ModeratePayload) => Promise<{ success: boolean }> + + // Event subscriptions (return cleanup functions) + onSuperChat: (handler: (data: SuperChatBroadcast) => void) => () => void + onModerate: (handler: (data: ModerateBroadcast) => void) => () => void + onViewerCount: (handler: (data: ViewerCountBroadcast) => void) => () => void +} + +export function useBroadcast(options: UseBroadcastOptions): UseBroadcastReturn { + const [client, setClient] = useState(null) + const [connected, setConnected] = useState(false) + const [connecting, setConnecting] = useState(false) + const [error, setError] = useState(null) + + const clientRef = useRef(null) + + // Initialize client + useEffect(() => { + const broadcastClient = new BroadcastNamespace({ + url: options.url, + token: options.token, + reconnection: options.reconnection, + reconnectionAttempts: options.reconnectionAttempts, + reconnectionDelay: options.reconnectionDelay, + reconnectionDelayMax: options.reconnectionDelayMax, + autoConnect: options.autoConnect !== false, + }) + + clientRef.current = broadcastClient + setClient(broadcastClient) + + const socket = broadcastClient.getSocket() + if (socket) { + setConnecting(true) + + socket.on('connect', () => { + setConnected(true) + setConnecting(false) + setError(null) + }) + + socket.on('disconnect', () => { + setConnected(false) + }) + + socket.on('connect_error', (err: Error) => { + setError(err) + setConnecting(false) + }) + } + + return () => { + broadcastClient.disconnect() + } + }, [ + options.url, + options.token, + options.autoConnect, + options.reconnection, + options.reconnectionAttempts, + options.reconnectionDelay, + options.reconnectionDelayMax, + ]) + + // Connection methods + const connect = useCallback(() => { + if (clientRef.current) { + clientRef.current.connect() + setConnecting(true) + } + }, []) + + const disconnect = useCallback(() => { + if (clientRef.current) { + clientRef.current.disconnect() + setConnected(false) + setConnecting(false) + } + }, []) + + // Broadcast methods + const joinRoom = useCallback(async (roomId: string) => { + if (!clientRef.current) { + return { success: false } + } + return await clientRef.current.joinRoom(roomId) + }, []) + + const sendSuperChat = useCallback(async (payload: SuperChatPayload) => { + if (!clientRef.current) { + return { success: false, error: 'Client not initialized' } + } + return await clientRef.current.sendSuperChat(payload) + }, []) + + const moderate = useCallback(async (payload: ModeratePayload) => { + if (!clientRef.current) { + return { success: false } + } + return await clientRef.current.moderate(payload) + }, []) + + // Event subscriptions + const onSuperChat = useCallback((handler: (data: SuperChatBroadcast) => void) => { + if (!clientRef.current) { + return () => {} + } + return clientRef.current.onSuperChat(handler) + }, []) + + const onModerate = useCallback((handler: (data: ModerateBroadcast) => void) => { + if (!clientRef.current) { + return () => {} + } + return clientRef.current.onModerate(handler) + }, []) + + const onViewerCount = useCallback((handler: (data: ViewerCountBroadcast) => void) => { + if (!clientRef.current) { + return () => {} + } + return clientRef.current.onViewerCount(handler) + }, []) + + return { + client, + connected, + connecting, + error, + connect, + disconnect, + joinRoom, + sendSuperChat, + moderate, + onSuperChat, + onModerate, + onViewerCount, + } +} diff --git a/client/src/hooks/useChat.ts b/client/src/hooks/useChat.ts new file mode 100644 index 0000000..f4cd4e1 --- /dev/null +++ b/client/src/hooks/useChat.ts @@ -0,0 +1,177 @@ +/** + * useChat Hook + * + * React hook for /chat namespace connection management + */ + +import { useEffect, useState, useCallback, useRef } from 'react' + +import { ChatNamespace } from '../namespaces/ChatNamespace' + +import type { ChatMessage, ChatTypingBroadcast, NamespaceConfig } from '../types' + +export interface UseChatOptions extends Omit { + autoConnect?: boolean +} + +export interface UseChatReturn { + client: ChatNamespace | null + connected: boolean + connecting: boolean + error: Error | null + + // Connection methods + connect: () => void + disconnect: () => void + + // Chat methods + joinRoom: (roomId: string) => Promise<{ success: boolean; error?: string }> + leaveRoom: (roomId: string) => Promise<{ success: boolean }> + sendMessage: (roomId: string, content: string) => Promise<{ success: boolean; messageId?: string }> + setTyping: (roomId: string, typing: boolean) => Promise<{ success: boolean }> + markAsRead: (messageId: string) => Promise<{ success: boolean }> + + // Event subscriptions (return cleanup functions) + onMessage: (handler: (message: ChatMessage) => void) => () => void + onTyping: (handler: (data: ChatTypingBroadcast) => void) => () => void +} + +export function useChat(options: UseChatOptions): UseChatReturn { + const [client, setClient] = useState(null) + const [connected, setConnected] = useState(false) + const [connecting, setConnecting] = useState(false) + const [error, setError] = useState(null) + + const clientRef = useRef(null) + + // Initialize client + useEffect(() => { + const chatClient = new ChatNamespace({ + url: options.url, + token: options.token, + reconnection: options.reconnection, + reconnectionAttempts: options.reconnectionAttempts, + reconnectionDelay: options.reconnectionDelay, + reconnectionDelayMax: options.reconnectionDelayMax, + autoConnect: options.autoConnect !== false, + }) + + clientRef.current = chatClient + setClient(chatClient) + + const socket = chatClient.getSocket() + if (socket) { + setConnecting(true) + + socket.on('connect', () => { + setConnected(true) + setConnecting(false) + setError(null) + }) + + socket.on('disconnect', () => { + setConnected(false) + }) + + socket.on('connect_error', (err: Error) => { + setError(err) + setConnecting(false) + }) + } + + return () => { + chatClient.disconnect() + } + }, [ + options.url, + options.token, + options.autoConnect, + options.reconnection, + options.reconnectionAttempts, + options.reconnectionDelay, + options.reconnectionDelayMax, + ]) + + // Connection methods + const connect = useCallback(() => { + if (clientRef.current) { + clientRef.current.connect() + setConnecting(true) + } + }, []) + + const disconnect = useCallback(() => { + if (clientRef.current) { + clientRef.current.disconnect() + setConnected(false) + setConnecting(false) + } + }, []) + + // Chat methods + const joinRoom = useCallback(async (roomId: string) => { + if (!clientRef.current) { + return { success: false, error: 'Client not initialized' } + } + return await clientRef.current.joinRoom(roomId) + }, []) + + const leaveRoom = useCallback(async (roomId: string) => { + if (!clientRef.current) { + return { success: false } + } + return await clientRef.current.leaveRoom(roomId) + }, []) + + const sendMessage = useCallback(async (roomId: string, content: string) => { + if (!clientRef.current) { + return { success: false } + } + return await clientRef.current.sendMessage({ roomId, content, messageType: 'TEXT' }) + }, []) + + const setTyping = useCallback(async (roomId: string, typing: boolean) => { + if (!clientRef.current) { + return { success: false } + } + return await clientRef.current.setTyping(roomId, typing) + }, []) + + const markAsRead = useCallback(async (messageId: string) => { + if (!clientRef.current) { + return { success: false } + } + return await clientRef.current.markAsRead(messageId) + }, []) + + // Event subscriptions + const onMessage = useCallback((handler: (message: ChatMessage) => void) => { + if (!clientRef.current) { + return () => {} + } + return clientRef.current.onMessage(handler) + }, []) + + const onTyping = useCallback((handler: (data: ChatTypingBroadcast) => void) => { + if (!clientRef.current) { + return () => {} + } + return clientRef.current.onTyping(handler) + }, []) + + return { + client, + connected, + connecting, + error, + connect, + disconnect, + joinRoom, + leaveRoom, + sendMessage, + setTyping, + markAsRead, + onMessage, + onTyping, + } +} diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..44e2ca9 --- /dev/null +++ b/client/src/hooks/useWebSocket.ts @@ -0,0 +1,93 @@ +/** + * useWebSocket Hook + * + * Main WebSocket connection hook for React components. + * Manages connection lifecycle and provides socket instance. + * + * Usage: + * const { socket, connected, connecting, error } = useWebSocket({ + * url: 'ws://localhost:4001', + * token: userToken, + * }); + */ + +import { useEffect, useState, useRef } from 'react' + + +import { WebSocketClient } from '../client' + +import type { WebSocketClientConfig, WebSocketConnectionState } from '../types' +import type { Socket } from 'socket.io-client' + +export interface UseWebSocketReturn extends WebSocketConnectionState { + socket: Socket | null; + client: WebSocketClient | null; +} + +export function useWebSocket(config: WebSocketClientConfig): UseWebSocketReturn { + const [state, setState] = useState({ + connected: false, + connecting: false, + error: null, + }) + + const clientRef = useRef(null) + const [socket, setSocket] = useState(null) + + useEffect(() => { + // Create client instance + const client = new WebSocketClient({ + url: config.url, + token: config.token, + reconnection: config.reconnection, + reconnectionAttempts: config.reconnectionAttempts, + reconnectionDelay: config.reconnectionDelay, + reconnectionDelayMax: config.reconnectionDelayMax, + autoConnect: false, // We control connection in useEffect + }) + + clientRef.current = client + + // Connect + setState({ connected: false, connecting: true, error: null }) + const socketInstance = client.connect() + setSocket(socketInstance) + + // Setup event handlers + const handleConnect = () => { + setState({ connected: true, connecting: false, error: null }) + } + + const handleDisconnect = () => { + setState({ connected: false, connecting: false, error: null }) + } + + const handleConnectError = (error: Error) => { + setState({ connected: false, connecting: false, error }) + } + + socketInstance.on('connect', handleConnect) + socketInstance.on('disconnect', handleDisconnect) + socketInstance.on('connect_error', handleConnectError) + + // Cleanup on unmount + return () => { + client.disconnect() + clientRef.current = null + setSocket(null) + } + }, [ + config.url, + config.token, + config.reconnection, + config.reconnectionAttempts, + config.reconnectionDelay, + config.reconnectionDelayMax, + ]) // Reconnect if config changes + + return { + socket, + client: clientRef.current, + ...state, + } +} diff --git a/client/src/index.ts b/client/src/index.ts new file mode 100644 index 0000000..42fce13 --- /dev/null +++ b/client/src/index.ts @@ -0,0 +1,40 @@ +/** + * @websocket/client + * + * Generic WebSocket client library with React hooks for real-time features. + * + * @example + * ```tsx + * import { useWebSocket, useChat, useBroadcast } from '@websocket/client'; + * + * function MyComponent() { + * const { socket, connected } = useWebSocket({ + * url: 'ws://localhost:4001', + * token: userToken, + * }); + * + * const { messages, sendMessage } = useChat(socket, roomId); + * + * return
Connected: {connected ? 'Yes' : 'No'}
; + * } + * ``` + */ + +// Core client +export { WebSocketClient } from './client' + +// Namespace clients +export { ChatNamespace, BroadcastNamespace } from './namespaces' + +// React hooks (generic only) +export { useWebSocket } from './hooks/useWebSocket' +export { useChat } from './hooks/useChat' +export { useBroadcast } from './hooks/useBroadcast' + +// Types +export * from './types' + +// Hook return types +export type { UseWebSocketReturn } from './hooks/useWebSocket' +export type { UseChatOptions, UseChatReturn } from './hooks/useChat' +export type { UseBroadcastOptions, UseBroadcastReturn } from './hooks/useBroadcast' diff --git a/client/src/namespaces/BroadcastNamespace.ts b/client/src/namespaces/BroadcastNamespace.ts new file mode 100644 index 0000000..b18e621 --- /dev/null +++ b/client/src/namespaces/BroadcastNamespace.ts @@ -0,0 +1,262 @@ +/** + * Broadcast Namespace Client + * + * Handles /broadcast namespace connections for live streaming, super chats, and moderation + */ + +import { io } from 'socket.io-client' + +import type { + NamespaceConfig, + SuperChatPayload, + SuperChatResponse, + SuperChatBroadcast, + ModeratePayload, + ModerateBroadcast, + ViewerCountBroadcast, +} from '../types' +import type { Socket, ManagerOptions, SocketOptions } from 'socket.io-client'; + +export class BroadcastNamespace { + private socket: Socket | null = null + private config: Required + private reconnectAttempt = 0 + private reconnectTimer: NodeJS.Timeout | null = null + + constructor(config: Omit) { + this.config = { + url: config.url, + namespace: '/broadcast', + token: config.token || '', + reconnection: config.reconnection !== false, + reconnectionAttempts: config.reconnectionAttempts || Infinity, + reconnectionDelay: config.reconnectionDelay || 1000, + reconnectionDelayMax: config.reconnectionDelayMax || 5000, + autoConnect: config.autoConnect !== false, + } + + if (this.config.autoConnect) { + this.connect() + } + } + + /** + * Connect to /broadcast namespace + */ + connect(): Socket { + if (this.socket?.connected) { + console.warn('[BroadcastNamespace] Already connected') + return this.socket + } + + const socketOptions: Partial = { + reconnection: false, // Manual reconnection with exponential backoff + transports: ['websocket', 'polling'], + } + + // Add authentication + if (this.config.token) { + socketOptions.auth = { token: this.config.token } + socketOptions.query = { token: this.config.token } + } + + // Connect to /broadcast namespace + const fullUrl = `${this.config.url}${this.config.namespace}` + this.socket = io(fullUrl, socketOptions) + + this.setupConnectionHandlers() + + return this.socket + } + + /** + * Disconnect from /broadcast namespace + */ + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (this.socket) { + this.socket.removeAllListeners() + this.socket.disconnect() + this.socket = null + } + + this.reconnectAttempt = 0 + } + + /** + * Get the underlying Socket.IO socket instance + */ + getSocket(): Socket | null { + return this.socket + } + + /** + * Check if currently connected + */ + isConnected(): boolean { + return this.socket?.connected || false + } + + // ============================================================================ + // Broadcast-specific methods + // ============================================================================ + + /** + * Join a broadcast room (as viewer) + */ + joinRoom(roomId: string): Promise<{ success: boolean }> { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false }) + return + } + + this.socket.emit('broadcast:join', { roomId }, (response: { success: boolean }) => { + resolve(response) + }) + }) + } + + /** + * Send a super chat (paid message) + */ + sendSuperChat(payload: SuperChatPayload): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false, error: 'Not connected' }) + return + } + + this.socket.emit('broadcast:super-chat', payload, (response: SuperChatResponse) => { + resolve(response) + }) + }) + } + + /** + * Moderate a user or message (moderator only) + */ + moderate(payload: ModeratePayload): Promise<{ success: boolean }> { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false }) + return + } + + this.socket.emit('broadcast:moderate', payload, (response: { success: boolean }) => { + resolve(response) + }) + }) + } + + // ============================================================================ + // Event listeners + // ============================================================================ + + /** + * Listen for super chat messages + */ + onSuperChat(handler: (data: SuperChatBroadcast) => void): () => void { + if (!this.socket) { + return () => {} + } + + this.socket.on('broadcast:super-chat', handler) + return () => this.socket?.off('broadcast:super-chat', handler) + } + + /** + * Listen for moderation events + */ + onModerate(handler: (data: ModerateBroadcast) => void): () => void { + if (!this.socket) { + return () => {} + } + + this.socket.on('broadcast:moderate', handler) + return () => this.socket?.off('broadcast:moderate', handler) + } + + /** + * Listen for viewer count updates + */ + onViewerCount(handler: (data: ViewerCountBroadcast) => void): () => void { + if (!this.socket) { + return () => {} + } + + this.socket.on('broadcast:viewer-count', handler) + return () => this.socket?.off('broadcast:viewer-count', handler) + } + + // ============================================================================ + // Connection management (private) + // ============================================================================ + + private setupConnectionHandlers(): void { + if (!this.socket) {return} + + this.socket.on('connect', () => { + console.log('[BroadcastNamespace] Connected to /broadcast') + this.reconnectAttempt = 0 + }) + + this.socket.on('disconnect', (reason) => { + console.log('[BroadcastNamespace] Disconnected:', reason) + + if ( + this.config.reconnection && + reason !== 'io client disconnect' && + this.reconnectAttempt < this.config.reconnectionAttempts + ) { + this.scheduleReconnect() + } + }) + + this.socket.on('connect_error', (error) => { + console.error('[BroadcastNamespace] Connection error:', error.message) + + if ( + this.config.reconnection && + this.reconnectAttempt < this.config.reconnectionAttempts + ) { + this.scheduleReconnect() + } + }) + + this.socket.on('error', (error) => { + console.error('[BroadcastNamespace] Socket error:', error) + }) + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) { + return + } + + this.reconnectAttempt++ + + const delay = Math.min( + this.config.reconnectionDelay * Math.pow(2, this.reconnectAttempt - 1), + this.config.reconnectionDelayMax, + ) + + console.log( + `[BroadcastNamespace] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt}/${this.config.reconnectionAttempts})`, + ) + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + + if (this.socket) { + this.socket.connect() + } else { + this.connect() + } + }, delay) + } +} diff --git a/client/src/namespaces/ChatNamespace.ts b/client/src/namespaces/ChatNamespace.ts new file mode 100644 index 0000000..dbf694b --- /dev/null +++ b/client/src/namespaces/ChatNamespace.ts @@ -0,0 +1,294 @@ +/** + * Chat Namespace Client + * + * Handles /chat namespace connections for direct messages, group chats, and support tickets + */ + +import { io } from 'socket.io-client' + +import type { + NamespaceConfig, + ChatMessage, + ChatJoinResponse, + ChatMessagePayload, + ChatMessageResponse, + ChatTypingBroadcast, + AuthenticatedPayload, +} from '../types' +import type { Socket, ManagerOptions, SocketOptions } from 'socket.io-client'; + +export class ChatNamespace { + private socket: Socket | null = null + private config: Required + private reconnectAttempt = 0 + private reconnectTimer: NodeJS.Timeout | null = null + + constructor(config: Omit) { + this.config = { + url: config.url, + namespace: '/chat', + token: config.token || '', + reconnection: config.reconnection !== false, + reconnectionAttempts: config.reconnectionAttempts || Infinity, + reconnectionDelay: config.reconnectionDelay || 1000, + reconnectionDelayMax: config.reconnectionDelayMax || 5000, + autoConnect: config.autoConnect !== false, + } + + if (this.config.autoConnect) { + this.connect() + } + } + + /** + * Connect to /chat namespace + */ + connect(): Socket { + if (this.socket?.connected) { + console.warn('[ChatNamespace] Already connected') + return this.socket + } + + const socketOptions: Partial = { + reconnection: false, // Manual reconnection with exponential backoff + transports: ['websocket', 'polling'], + } + + // Add authentication + if (this.config.token) { + socketOptions.auth = { token: this.config.token } + socketOptions.query = { token: this.config.token } + } + + // Connect to /chat namespace + const fullUrl = `${this.config.url}${this.config.namespace}` + this.socket = io(fullUrl, socketOptions) + + this.setupConnectionHandlers() + + return this.socket + } + + /** + * Disconnect from /chat namespace + */ + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (this.socket) { + this.socket.removeAllListeners() + this.socket.disconnect() + this.socket = null + } + + this.reconnectAttempt = 0 + } + + /** + * Get the underlying Socket.IO socket instance + */ + getSocket(): Socket | null { + return this.socket + } + + /** + * Check if currently connected + */ + isConnected(): boolean { + return this.socket?.connected || false + } + + // ============================================================================ + // Chat-specific methods + // ============================================================================ + + /** + * Join a chat room + */ + joinRoom(roomId: string): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false, error: 'Not connected' }) + return + } + + this.socket.emit('chat:join', { roomId }, (response: ChatJoinResponse) => { + resolve(response) + }) + }) + } + + /** + * Leave a chat room + */ + leaveRoom(roomId: string): Promise<{ success: boolean }> { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false }) + return + } + + this.socket.emit('chat:leave', { roomId }, (response: { success: boolean }) => { + resolve(response) + }) + }) + } + + /** + * Send a message + */ + sendMessage(payload: ChatMessagePayload): Promise { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false, error: 'Not connected' }) + return + } + + this.socket.emit('chat:message', payload, (response: ChatMessageResponse) => { + resolve(response) + }) + }) + } + + /** + * Set typing indicator + */ + setTyping(roomId: string, typing: boolean): Promise<{ success: boolean }> { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false }) + return + } + + this.socket.emit('chat:typing', { roomId, typing }, (response: { success: boolean }) => { + resolve(response) + }) + }) + } + + /** + * Mark message as read + */ + markAsRead(messageId: string): Promise<{ success: boolean }> { + return new Promise((resolve) => { + if (!this.socket?.connected) { + resolve({ success: false }) + return + } + + this.socket.emit('chat:read', { messageId }, (response: { success: boolean }) => { + resolve(response) + }) + }) + } + + // ============================================================================ + // Event listeners + // ============================================================================ + + /** + * Listen for authenticated event + */ + onAuthenticated(handler: (data: AuthenticatedPayload) => void): () => void { + if (!this.socket) { + return () => {} + } + + this.socket.on('authenticated', handler) + return () => this.socket?.off('authenticated', handler) + } + + /** + * Listen for incoming messages + */ + onMessage(handler: (message: ChatMessage) => void): () => void { + if (!this.socket) { + return () => {} + } + + this.socket.on('chat:message', handler) + return () => this.socket?.off('chat:message', handler) + } + + /** + * Listen for typing indicators + */ + onTyping(handler: (data: ChatTypingBroadcast) => void): () => void { + if (!this.socket) { + return () => {} + } + + this.socket.on('chat:typing', handler) + return () => this.socket?.off('chat:typing', handler) + } + + // ============================================================================ + // Connection management (private) + // ============================================================================ + + private setupConnectionHandlers(): void { + if (!this.socket) {return} + + this.socket.on('connect', () => { + console.log('[ChatNamespace] Connected to /chat') + this.reconnectAttempt = 0 + }) + + this.socket.on('disconnect', (reason) => { + console.log('[ChatNamespace] Disconnected:', reason) + + if ( + this.config.reconnection && + reason !== 'io client disconnect' && + this.reconnectAttempt < this.config.reconnectionAttempts + ) { + this.scheduleReconnect() + } + }) + + this.socket.on('connect_error', (error) => { + console.error('[ChatNamespace] Connection error:', error.message) + + if ( + this.config.reconnection && + this.reconnectAttempt < this.config.reconnectionAttempts + ) { + this.scheduleReconnect() + } + }) + + this.socket.on('error', (error) => { + console.error('[ChatNamespace] Socket error:', error) + }) + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) { + return + } + + this.reconnectAttempt++ + + const delay = Math.min( + this.config.reconnectionDelay * Math.pow(2, this.reconnectAttempt - 1), + this.config.reconnectionDelayMax, + ) + + console.log( + `[ChatNamespace] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt}/${this.config.reconnectionAttempts})`, + ) + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + + if (this.socket) { + this.socket.connect() + } else { + this.connect() + } + }, delay) + } +} diff --git a/client/src/namespaces/index.ts b/client/src/namespaces/index.ts new file mode 100644 index 0000000..fc6d759 --- /dev/null +++ b/client/src/namespaces/index.ts @@ -0,0 +1,6 @@ +/** + * Namespace clients for Socket.IO + */ + +export { ChatNamespace } from './ChatNamespace' +export { BroadcastNamespace } from './BroadcastNamespace' diff --git a/client/src/types/index.ts b/client/src/types/index.ts new file mode 100644 index 0000000..e666e96 --- /dev/null +++ b/client/src/types/index.ts @@ -0,0 +1,32 @@ +/** + * Type exports for @websocket/client + */ + +export * from './messaging-events' + +export interface WebSocketClientConfig { + url: string; + token?: string; + reconnection?: boolean; + reconnectionAttempts?: number; + reconnectionDelay?: number; + reconnectionDelayMax?: number; + autoConnect?: boolean; +} + +export interface WebSocketConnectionState { + connected: boolean; + connecting: boolean; + error: Error | null; +} + +export interface NamespaceConfig { + url: string + namespace: string + token?: string + reconnection?: boolean + reconnectionAttempts?: number + reconnectionDelay?: number + reconnectionDelayMax?: number + autoConnect?: boolean +} diff --git a/client/src/types/messaging-events.ts b/client/src/types/messaging-events.ts new file mode 100644 index 0000000..745f86c --- /dev/null +++ b/client/src/types/messaging-events.ts @@ -0,0 +1,125 @@ +/** + * Chat and Broadcast Event Type Definitions + * + * Based on @services/platform/src/features/websocket/gateways/chat.gateway.ts + */ + +// ============================================================================ +// Chat Events (/chat namespace) +// ============================================================================ + +export interface ChatMessage { + id: string + roomId: string + senderId: string + content: string + messageType: 'TEXT' | 'IMAGE' | 'EMOJI' | 'SYSTEM' + metadata?: Record + deliveredTo: string[] + readBy: string[] + createdAt: string + editedAt?: string + + // Enhanced fields (Phase 6) + priority?: 'NORMAL' | 'VIP' | 'WHALE' | 'URGENT' + orderId?: string + superChatAmount?: number + superChatCurrency?: string + senderType?: 'user' | 'bot' | 'system' +} + +export interface ChatJoinPayload { + roomId: string +} + +export interface ChatJoinResponse { + success: boolean + roomId?: string + error?: string +} + +export interface ChatLeavePayload { + roomId: string +} + +export interface ChatMessagePayload { + roomId: string + content: string + messageType?: 'TEXT' | 'IMAGE' | 'EMOJI' + metadata?: Record +} + +export interface ChatMessageResponse { + success: boolean + messageId?: string + error?: string +} + +export interface ChatTypingPayload { + roomId: string + typing: boolean +} + +export interface ChatTypingBroadcast { + userId: string + typing: boolean +} + +export interface ChatReadPayload { + messageId: string +} + +export interface AuthenticatedPayload { + success: boolean + userId: string +} + +// ============================================================================ +// Broadcast Events (/broadcast namespace) +// ============================================================================ + +export interface SuperChatPayload { + roomId: string + content: string + amount: number + currency: string +} + +export interface SuperChatResponse { + success: boolean + messageId?: string + error?: string +} + +export interface SuperChatBroadcast extends ChatMessage { + superChatAmount: number + superChatCurrency: string +} + +export interface ModeratePayload { + roomId: string + userId: string + action: 'ban' | 'timeout' | 'delete' + targetUserId?: string + messageId?: string + duration?: number // seconds for timeout + reason?: string +} + +export interface ModerateBroadcast { + roomId: string + action: 'ban' | 'timeout' | 'delete' + targetUserId?: string + messageId?: string + moderatorId: string + timestamp: string +} + +export interface BroadcastJoinPayload { + roomId: string +} + +export interface ViewerCountBroadcast { + roomId: string + count: number +} diff --git a/client/tsconfig.eslint.json b/client/tsconfig.eslint.json new file mode 100644 index 0000000..e06a179 --- /dev/null +++ b/client/tsconfig.eslint.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*", + "test/**/*", + "*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..9476a59 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@lilith/configs/typescript/react.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.test.tsx" + ] +}