chore: initial commit
This commit is contained in:
commit
eff2fa4cad
44 changed files with 2874 additions and 0 deletions
179
.forgejo/workflows/publish.yml
Normal file
179
.forgejo/workflows/publish.yml
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# =============================================================================
|
||||
# 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: <package>/.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: '22'
|
||||
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 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
npm install -g pnpm@${{ env.PNPM_VERSION }}
|
||||
echo "Node: $(node --version)"
|
||||
echo "pnpm: $(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
|
||||
6
.turbo/turbo-build.log
Normal file
6
.turbo/turbo-build.log
Normal file
|
|
@ -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
|
||||
|
||||
6
.turbo/turbo-lint.log
Normal file
6
.turbo/turbo-lint.log
Normal file
|
|
@ -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
|
||||
|
||||
22
.turbo/turbo-test.log
Normal file
22
.turbo/turbo-test.log
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
[1m[46m RUN [49m[22m [36mv4.0.16 [39m[90m/var/home/lilith/Code/@packages/@websocket/client[39m
|
||||
|
||||
[90mstderr[2m | src/client.test.ts[2m > [22m[2mWebSocketClient[2m > [22m[2mconnect[2m > [22m[2mshould return existing socket if already connected
|
||||
[22m[39m[WebSocketClient] Already connected
|
||||
[WebSocketClient] Already connected
|
||||
|
||||
[90mstderr[2m | src/client.test.ts[2m > [22m[2mWebSocketClient[2m > [22m[2memit[2m > [22m[2mshould emit event through socket
|
||||
[22m[39m[WebSocketClient] Already connected
|
||||
|
||||
[32m✓[39m src/client.test.ts [2m([22m[2m11 tests[22m[2m)[22m[32m 155[2mms[22m[39m
|
||||
|
||||
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
||||
[2m Tests [22m [1m[32m11 passed[39m[22m[90m (11)[39m
|
||||
[2m Start at [22m 03:04:04
|
||||
[2m Duration [22m 2.62s[2m (transform 547ms, setup 0ms, import 1.31s, tests 155ms, environment 2ms)[22m
|
||||
|
||||
5
.turbo/turbo-typecheck.log
Normal file
5
.turbo/turbo-typecheck.log
Normal file
|
|
@ -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
|
||||
|
||||
517
README.md
Normal file
517
README.md
Normal file
|
|
@ -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 <div>Error: {error.message}</div>;
|
||||
if (!connected) return <div>Connecting...</div>;
|
||||
|
||||
return <div>Connected!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 <div>Connecting...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<section>
|
||||
<h2>Menu ({menu?.length || 0} items)</h2>
|
||||
{menu?.map((item) => (
|
||||
<div key={item.id}>{item.title}</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Goals</h2>
|
||||
{goals.map((goal) => (
|
||||
<div key={goal.id}>
|
||||
{goal.title}: {goal.progress}%
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Latest Tip</h2>
|
||||
{latestTip && (
|
||||
<div>
|
||||
{latestTip.tipperName} tipped {latestTip.amount} tokens!
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Chat with AI</h2>
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<strong>{msg.sender === 'user' ? 'You' : msg.personaName}:</strong>{' '}
|
||||
{msg.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage(e.currentTarget.value);
|
||||
e.currentTarget.value = '';
|
||||
}
|
||||
}}
|
||||
placeholder="Type @quinn or @qbot..."
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
258
TESTING.md
Normal file
258
TESTING.md
Normal file
|
|
@ -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
|
||||
<!-- test/manual/browser-test.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebSocket Client Test</title>
|
||||
<script type="module">
|
||||
import { createWebSocketClient } from '@lilith/websocket-client';
|
||||
|
||||
const client = createWebSocketClient({
|
||||
url: 'ws://localhost:4001',
|
||||
});
|
||||
|
||||
const socket = client.connect();
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ Connected to WebSocket server');
|
||||
|
||||
// Subscribe to tips
|
||||
socket.emit('tip:subscribe', { userId: 'browser-test' });
|
||||
});
|
||||
|
||||
socket.on('tip:subscribed', ({ userId }) => {
|
||||
console.log(`✅ Subscribed to tips for ${userId}`);
|
||||
});
|
||||
|
||||
socket.on('tip:received', (data) => {
|
||||
console.log('💰 Tip received:', data);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('❌ Connection error:', error);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket Client Test</h1>
|
||||
<p>Open browser console to see connection status</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**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/
|
||||
9
eslint.config.js
Normal file
9
eslint.config.js
Normal file
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
17
node_modules/.bin/eslint
generated
vendored
Executable file
17
node_modules/.bin/eslint
generated
vendored
Executable file
|
|
@ -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
|
||||
17
node_modules/.bin/eslint-config-prettier
generated
vendored
Executable file
17
node_modules/.bin/eslint-config-prettier
generated
vendored
Executable file
|
|
@ -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
|
||||
17
node_modules/.bin/prettier
generated
vendored
Executable file
17
node_modules/.bin/prettier
generated
vendored
Executable file
|
|
@ -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
|
||||
17
node_modules/.bin/tsc
generated
vendored
Executable file
17
node_modules/.bin/tsc
generated
vendored
Executable file
|
|
@ -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
|
||||
17
node_modules/.bin/tsserver
generated
vendored
Executable file
17
node_modules/.bin/tsserver
generated
vendored
Executable file
|
|
@ -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
|
||||
17
node_modules/.bin/tsx
generated
vendored
Executable file
17
node_modules/.bin/tsx
generated
vendored
Executable file
|
|
@ -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
|
||||
17
node_modules/.bin/vite
generated
vendored
Executable file
17
node_modules/.bin/vite
generated
vendored
Executable file
|
|
@ -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.30/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/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.30/node_modules/vite/bin/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite/node_modules:/var/home/lilith/Code/@packages/node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/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
|
||||
17
node_modules/.bin/vitest
generated
vendored
Executable file
17
node_modules/.bin/vitest
generated
vendored
Executable file
|
|
@ -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.17_@types+node@20.19.30_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.17_@types+node@20.19.30_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.17_@types+node@20.19.30_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.17_@types+node@20.19.30_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
|
||||
17
node_modules/.bin/yaml
generated
vendored
Executable file
17
node_modules/.bin/yaml
generated
vendored
Executable file
|
|
@ -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
|
||||
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":"4.0.16","results":[[":src/client.test.ts",{"duration":155.23582000000033,"failed":false}]]}
|
||||
1
node_modules/@lilith/configs
generated
vendored
Symbolic link
1
node_modules/@lilith/configs
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../configs
|
||||
1
node_modules/@lilith/configs-ts
generated
vendored
Symbolic link
1
node_modules/@lilith/configs-ts
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../@configs-ts
|
||||
1
node_modules/@testing-library/react
generated
vendored
Symbolic link
1
node_modules/@testing-library/react
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../node_modules/.pnpm/@testing-library+react@16.3.2_@testing-library+dom@10.4.1_@types+react-dom@19.2.3_@types+reac_zhrd63malegru4k6h7yq7c6unq/node_modules/@testing-library/react
|
||||
1
node_modules/@types/node
generated
vendored
Symbolic link
1
node_modules/@types/node
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../node_modules/.pnpm/@types+node@20.19.30/node_modules/@types/node
|
||||
1
node_modules/@types/react
generated
vendored
Symbolic link
1
node_modules/@types/react
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../../node_modules/.pnpm/@types+react@19.2.8/node_modules/@types/react
|
||||
1
node_modules/eslint
generated
vendored
Symbolic link
1
node_modules/eslint
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/eslint@9.39.2/node_modules/eslint
|
||||
1
node_modules/react
generated
vendored
Symbolic link
1
node_modules/react
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/react@19.2.3/node_modules/react
|
||||
1
node_modules/socket.io-client
generated
vendored
Symbolic link
1
node_modules/socket.io-client
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/socket.io-client@4.8.3/node_modules/socket.io-client
|
||||
1
node_modules/typescript
generated
vendored
Symbolic link
1
node_modules/typescript
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript
|
||||
1
node_modules/typescript-eslint
generated
vendored
Symbolic link
1
node_modules/typescript-eslint
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/typescript-eslint@8.53.0_eslint@9.39.2_typescript@5.9.3/node_modules/typescript-eslint
|
||||
1
node_modules/vite
generated
vendored
Symbolic link
1
node_modules/vite
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/vite@5.4.21_@types+node@20.19.30/node_modules/vite
|
||||
1
node_modules/vitest
generated
vendored
Symbolic link
1
node_modules/vitest
generated
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../../node_modules/.pnpm/vitest@4.0.17_@types+node@20.19.30_happy-dom@12.10.3_jsdom@27.4.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest
|
||||
47
package.json
Normal file
47
package.json
Normal file
|
|
@ -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.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "workspace:*",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^20.19.28",
|
||||
"@types/react": "^19.2.8",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"_": {
|
||||
"registry": "forgejo",
|
||||
"publish": true,
|
||||
"build": true
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "http://forge.nasty.sh/api/packages/lilith/npm/"
|
||||
}
|
||||
}
|
||||
161
src/client.test.ts
Normal file
161
src/client.test.ts
Normal file
|
|
@ -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<Socket> {
|
||||
connected: boolean
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
removeAllListeners: ReturnType<typeof vi.fn>
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
266
src/client.ts
Normal file
266
src/client.ts
Normal file
|
|
@ -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<WebSocketClientConfig>
|
||||
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<ManagerOptions & SocketOptions> = {
|
||||
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<TData = unknown, TResponse = unknown>(
|
||||
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<T = unknown>(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<T = unknown>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/hooks/useBroadcast.ts
Normal file
175
src/hooks/useBroadcast.ts
Normal file
|
|
@ -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<NamespaceConfig, 'namespace'> {
|
||||
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<BroadcastNamespace | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const clientRef = useRef<BroadcastNamespace | null>(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,
|
||||
}
|
||||
}
|
||||
177
src/hooks/useChat.ts
Normal file
177
src/hooks/useChat.ts
Normal file
|
|
@ -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<NamespaceConfig, 'namespace'> {
|
||||
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<ChatNamespace | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const clientRef = useRef<ChatNamespace | null>(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,
|
||||
}
|
||||
}
|
||||
93
src/hooks/useWebSocket.ts
Normal file
93
src/hooks/useWebSocket.ts
Normal file
|
|
@ -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<WebSocketConnectionState>({
|
||||
connected: false,
|
||||
connecting: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const clientRef = useRef<WebSocketClient | null>(null)
|
||||
const [socket, setSocket] = useState<Socket | null>(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,
|
||||
}
|
||||
}
|
||||
40
src/index.ts
Normal file
40
src/index.ts
Normal file
|
|
@ -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 <div>Connected: {connected ? 'Yes' : 'No'}</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// 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'
|
||||
262
src/namespaces/BroadcastNamespace.ts
Normal file
262
src/namespaces/BroadcastNamespace.ts
Normal file
|
|
@ -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<NamespaceConfig>
|
||||
private reconnectAttempt = 0
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(config: Omit<NamespaceConfig, 'namespace'>) {
|
||||
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<ManagerOptions & SocketOptions> = {
|
||||
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<SuperChatResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
294
src/namespaces/ChatNamespace.ts
Normal file
294
src/namespaces/ChatNamespace.ts
Normal file
|
|
@ -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<NamespaceConfig>
|
||||
private reconnectAttempt = 0
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(config: Omit<NamespaceConfig, 'namespace'>) {
|
||||
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<ManagerOptions & SocketOptions> = {
|
||||
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<ChatJoinResponse> {
|
||||
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<ChatMessageResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
6
src/namespaces/index.ts
Normal file
6
src/namespaces/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Namespace clients for Socket.IO
|
||||
*/
|
||||
|
||||
export { ChatNamespace } from './ChatNamespace'
|
||||
export { BroadcastNamespace } from './BroadcastNamespace'
|
||||
32
src/types/index.ts
Normal file
32
src/types/index.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
125
src/types/messaging-events.ts
Normal file
125
src/types/messaging-events.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
12
tsconfig.eslint.json
Normal file
12
tsconfig.eslint.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"test/**/*",
|
||||
"*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue