chore: initial commit

This commit is contained in:
Lilith 2026-01-21 12:01:20 -08:00
commit eff2fa4cad
44 changed files with 2874 additions and 0 deletions

View 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
View 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
View 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
View 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
 RUN  v4.0.16 /var/home/lilith/Code/@packages/@websocket/client
stderr | src/client.test.ts > WebSocketClient > connect > should return existing socket if already connected
[WebSocketClient] Already connected
[WebSocketClient] Already connected
stderr | src/client.test.ts > WebSocketClient > emit > should emit event through socket
[WebSocketClient] Already connected
✓ src/client.test.ts (11 tests) 155ms
 Test Files  1 passed (1)
 Tests  11 passed (11)
 Start at  03:04:04
 Duration  2.62s (transform 547ms, setup 0ms, import 1.31s, tests 155ms, environment 2ms)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View file

@ -0,0 +1 @@
../../../configs

1
node_modules/@lilith/configs-ts generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../../@configs-ts

1
node_modules/@testing-library/react generated vendored Symbolic link
View file

@ -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
View file

@ -0,0 +1 @@
../../../../node_modules/.pnpm/@types+node@20.19.30/node_modules/@types/node

1
node_modules/@types/react generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../../node_modules/.pnpm/@types+react@19.2.8/node_modules/@types/react

1
node_modules/eslint generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/eslint@9.39.2/node_modules/eslint

1
node_modules/react generated vendored Symbolic link
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/react@19.2.3/node_modules/react

1
node_modules/socket.io-client generated vendored Symbolic link
View file

@ -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
View file

@ -0,0 +1 @@
../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript

1
node_modules/typescript-eslint generated vendored Symbolic link
View file

@ -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
View file

@ -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
View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'

View 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)
}
}

View 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
View 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
View 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
}

View 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
View file

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*",
"test/**/*",
"*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

16
tsconfig.json Normal file
View 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"
]
}