Create comprehensive Obsidian-style documentation structure
- Reorganize all markdown documentation into structured docs/ folder - Create 7 main documentation categories (00-overview through 06-deployment) - Add comprehensive index files for each category with cross-linking - Implement Obsidian-compatible [[link]] syntax throughout - Move legacy/deprecated documentation to archive folder - Establish documentation standards and maintenance guidelines - Provide complete coverage of modular architecture, services, and deployment - Enable better navigation and discoverability for developers and contributors 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
46856134ef
commit
cdf099e45f
29 changed files with 3733 additions and 0 deletions
109
docs/03-core-services/authentication.md
Normal file
109
docs/03-core-services/authentication.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Authentication System
|
||||
|
||||
This web application now uses LNBits username/password authentication instead of Nostr keypairs.
|
||||
|
||||
## Overview
|
||||
|
||||
The authentication system has been completely replaced with a traditional username/password system that integrates with LNBits. Users can now:
|
||||
|
||||
- Register new accounts with username and password
|
||||
- Login with username/email and password
|
||||
- Manage their profile information
|
||||
- Logout securely
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file in the `web-app` directory with the following variables:
|
||||
|
||||
```env
|
||||
# LNBits Base URL Configuration
|
||||
# Set this to your LNBits instance base URL
|
||||
# Example: http://localhost:5000 or https://your-lnbits-instance.com
|
||||
VITE_LNBITS_BASE_URL=http://localhost:5000
|
||||
|
||||
# Enable debug logging for LNBits API calls
|
||||
VITE_LNBITS_DEBUG=false
|
||||
|
||||
# App Configuration
|
||||
VITE_APP_TITLE=Ario
|
||||
VITE_APP_DESCRIPTION=Your secure platform for events and community management
|
||||
```
|
||||
|
||||
### LNBits Setup
|
||||
|
||||
1. Ensure your LNBits instance is running and accessible
|
||||
2. Make sure the username/password authentication method is enabled in LNBits
|
||||
3. Configure CORS if your LNBits instance is on a different domain
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The application uses the following LNBits API endpoints:
|
||||
|
||||
- `POST /api/v1/auth` - Login
|
||||
- `POST /api/v1/auth/register` - Register new user
|
||||
- `POST /api/v1/auth/logout` - Logout
|
||||
- `GET /api/v1/auth` - Get current user
|
||||
- `PUT /api/v1/auth/password` - Update password
|
||||
- `PUT /api/v1/auth/update` - Update profile
|
||||
|
||||
## Components
|
||||
|
||||
### New Components
|
||||
|
||||
- `LoginDialog.vue` - Modal dialog for login/register
|
||||
- `UserProfile.vue` - Display user information and logout
|
||||
- `Login.vue` - Full-page login/register form
|
||||
|
||||
### Updated Components
|
||||
|
||||
- `App.vue` - Now uses new authentication system
|
||||
- `Navbar.vue` - Shows user status and logout option
|
||||
- `Home.vue` - Displays welcome message and user profile
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. **App Initialization**: The app checks for existing authentication token on startup
|
||||
2. **Route Protection**: Routes with `requiresAuth: true` redirect to login if not authenticated
|
||||
3. **Login/Register**: Users can create accounts or login with existing credentials
|
||||
4. **Token Management**: Access tokens are stored in localStorage and automatically included in API requests
|
||||
5. **Logout**: Clears tokens and redirects to login page
|
||||
|
||||
## Security Features
|
||||
|
||||
- JWT tokens for session management
|
||||
- Secure password handling (handled by LNBits)
|
||||
- Automatic token refresh
|
||||
- Route protection for authenticated pages
|
||||
- Secure logout with token cleanup
|
||||
|
||||
## Migration from Nostr
|
||||
|
||||
The following components have been removed or replaced:
|
||||
|
||||
- `useIdentity.ts` → `useAuth.ts`
|
||||
- `IdentityDialog.vue` → `LoginDialog.vue`
|
||||
- `PasswordDialog.vue` → Integrated into `LoginDialog.vue`
|
||||
- Nostr connection status → User authentication status
|
||||
|
||||
## Development
|
||||
|
||||
To run the application with the new authentication system:
|
||||
|
||||
1. Set up your LNBits instance
|
||||
2. Configure the environment variables
|
||||
3. Run the development server: `npm run dev`
|
||||
4. Access the application and test login/register functionality
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **CORS Errors**: Ensure your LNBits instance allows requests from your frontend domain
|
||||
2. **Authentication Failures**: Check that username/password auth is enabled in LNBits
|
||||
3. **API Connection**: Verify the `VITE_LNBITS_BASE_URL` is correct and points to your LNBits instance (without /api/v1)
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging by setting `VITE_LNBITS_DEBUG=true` to see detailed API request/response information in the browser console.
|
||||
496
docs/03-core-services/index.md
Normal file
496
docs/03-core-services/index.md
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
# ⚙️ Core Services Overview
|
||||
|
||||
> **Shared infrastructure services** providing foundational functionality across all modules with reactive architecture, dependency injection, and lifecycle management.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [[#Service Architecture]]
|
||||
- [[#Available Services]]
|
||||
- [[#Service Lifecycle]]
|
||||
- [[#Dependency Injection]]
|
||||
- [[#Service Development]]
|
||||
- [[#Testing Services]]
|
||||
|
||||
## Service Architecture
|
||||
|
||||
### **BaseService Foundation**
|
||||
All core services extend the `BaseService` abstract class which provides:
|
||||
|
||||
- **Reactive State Management** - Integration with Vue's reactivity system
|
||||
- **Lifecycle Management** - Standardized initialization and disposal
|
||||
- **Error Handling** - Consistent error patterns across services
|
||||
- **Type Safety** - Full TypeScript support with strict typing
|
||||
|
||||
```typescript
|
||||
abstract class BaseService {
|
||||
protected isInitialized = ref(false)
|
||||
protected isDisposed = ref(false)
|
||||
|
||||
abstract initialize(): Promise<void>
|
||||
abstract dispose(): Promise<void>
|
||||
|
||||
// Reactive state helpers
|
||||
protected createReactiveState<T>(initialValue: T): Ref<T>
|
||||
protected createComputedState<T>(getter: () => T): ComputedRef<T>
|
||||
}
|
||||
```
|
||||
|
||||
### **Service Registration Pattern**
|
||||
Services are registered in the dependency injection container during module installation:
|
||||
|
||||
```typescript
|
||||
// Service registration (in base module)
|
||||
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
|
||||
container.provide(SERVICE_TOKENS.AUTH_SERVICE, authService)
|
||||
container.provide(SERVICE_TOKENS.STORAGE_SERVICE, storageService)
|
||||
|
||||
// Service consumption (anywhere in app)
|
||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||
const auth = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
||||
```
|
||||
|
||||
## Available Services
|
||||
|
||||
### **AuthService** 🔐
|
||||
**Purpose:** User authentication and identity management
|
||||
**Location:** `src/modules/base/auth/auth-service.ts`
|
||||
**Token:** `SERVICE_TOKENS.AUTH_SERVICE`
|
||||
|
||||
**Key Features:**
|
||||
- **Key Management** - Secure generation, import, and storage of Nostr keys
|
||||
- **User Sessions** - Persistent authentication with encrypted storage
|
||||
- **Profile Management** - User profile creation and updates
|
||||
- **Security** - Client-side key handling with no server storage
|
||||
|
||||
**Reactive State:**
|
||||
```typescript
|
||||
interface AuthService {
|
||||
user: Ref<NostrUser | null> // Current authenticated user
|
||||
isAuthenticated: ComputedRef<boolean> // Authentication status
|
||||
isLoading: Ref<boolean> // Loading state
|
||||
loginError: Ref<string | null> // Login error message
|
||||
}
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `generateKeyPair()` - Create new Nostr key pair
|
||||
- `loginWithPrivateKey(privateKey: string)` - Authenticate with existing key
|
||||
- `logout()` - Clear session and user data
|
||||
- `updateProfile(profile: UserMetadata)` - Update user profile
|
||||
|
||||
**See:** [[authentication|📖 Authentication Service Documentation]]
|
||||
|
||||
### **RelayHub** 🌐
|
||||
**Purpose:** Centralized Nostr relay connection management
|
||||
**Location:** `src/modules/base/nostr/relay-hub.ts`
|
||||
**Token:** `SERVICE_TOKENS.RELAY_HUB`
|
||||
|
||||
**Key Features:**
|
||||
- **Connection Management** - Automatic connection, reconnection, and failover
|
||||
- **Event Publishing** - Reliable event publishing across multiple relays
|
||||
- **Subscription Management** - Efficient event subscriptions with deduplication
|
||||
- **Performance Monitoring** - Relay latency and success rate tracking
|
||||
|
||||
**Reactive State:**
|
||||
```typescript
|
||||
interface RelayHub {
|
||||
connectedRelays: Ref<string[]> // Currently connected relays
|
||||
connectionStatus: ComputedRef<ConnectionStatus> // Overall connection status
|
||||
relayStats: Ref<Map<string, RelayStats>> // Per-relay statistics
|
||||
isConnecting: Ref<boolean> // Connection in progress
|
||||
}
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
- `connect(relays: string[])` - Connect to relay URLs
|
||||
- `publishEvent(event: NostrEvent)` - Publish event to all connected relays
|
||||
- `subscribe(filters: Filter[], callback: EventCallback)` - Subscribe to events
|
||||
- `getRelayInfo(url: string)` - Get relay connection information
|
||||
|
||||
**See:** [[../01-architecture/relay-hub|📖 Relay Hub Architecture Documentation]]
|
||||
|
||||
### **StorageService** 💾
|
||||
**Purpose:** User-scoped local storage operations
|
||||
**Location:** `src/core/services/StorageService.ts`
|
||||
**Token:** `SERVICE_TOKENS.STORAGE_SERVICE`
|
||||
|
||||
**Key Features:**
|
||||
- **User-Scoped Storage** - Automatic key prefixing per authenticated user
|
||||
- **Type-Safe Operations** - Strongly typed get/set operations with JSON serialization
|
||||
- **Reactive Updates** - Optional reactive storage with Vue refs
|
||||
- **Migration Support** - Data migration between storage schema versions
|
||||
|
||||
**Key Methods:**
|
||||
```typescript
|
||||
interface StorageService {
|
||||
setUserData<T>(key: string, data: T): void
|
||||
getUserData<T>(key: string, defaultValue?: T): T | undefined
|
||||
removeUserData(key: string): void
|
||||
clearUserData(): void
|
||||
|
||||
// Reactive variants
|
||||
getReactiveUserData<T>(key: string, defaultValue: T): Ref<T>
|
||||
setReactiveUserData<T>(key: string, ref: Ref<T>): void
|
||||
}
|
||||
```
|
||||
|
||||
**Storage Patterns:**
|
||||
- User-specific keys: `user:{pubkey}:settings`
|
||||
- Global application keys: `app:theme`
|
||||
- Module-specific keys: `user:{pubkey}:chat:contacts`
|
||||
|
||||
**See:** [[storage-service|📖 Storage Service Documentation]]
|
||||
|
||||
### **ToastService** 📢
|
||||
**Purpose:** Application-wide notifications and user feedback
|
||||
**Location:** `src/core/services/ToastService.ts`
|
||||
**Token:** `SERVICE_TOKENS.TOAST_SERVICE`
|
||||
|
||||
**Key Features:**
|
||||
- **Context-Specific Methods** - Pre-configured toasts for common scenarios
|
||||
- **Consistent Messaging** - Standardized success, error, and info messages
|
||||
- **Accessibility** - Screen reader compatible notifications
|
||||
- **Customizable** - Support for custom toast content and actions
|
||||
|
||||
**Organized by Context:**
|
||||
```typescript
|
||||
interface ToastService {
|
||||
// Authentication context
|
||||
auth: {
|
||||
loginSuccess(): void
|
||||
loginError(error?: string): void
|
||||
logoutSuccess(): void
|
||||
keyGenerated(): void
|
||||
}
|
||||
|
||||
// Payment context
|
||||
payment: {
|
||||
invoiceCreated(): void
|
||||
paymentReceived(): void
|
||||
paymentFailed(error?: string): void
|
||||
}
|
||||
|
||||
// Clipboard operations
|
||||
clipboard: {
|
||||
copied(item?: string): void
|
||||
copyFailed(): void
|
||||
}
|
||||
|
||||
// General operations
|
||||
operation: {
|
||||
success(message: string): void
|
||||
error(message: string): void
|
||||
info(message: string): void
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**See:** [[toast-service|📖 Toast Service Documentation]]
|
||||
|
||||
### **EventBus** 📡
|
||||
**Purpose:** Inter-module communication and event coordination
|
||||
**Location:** `src/core/services/EventBus.ts`
|
||||
**Token:** `SERVICE_TOKENS.EVENT_BUS`
|
||||
|
||||
**Key Features:**
|
||||
- **Type-Safe Events** - Strongly typed event payloads
|
||||
- **Module Isolation** - Clean communication between modules
|
||||
- **Event Namespacing** - Organized event names by domain
|
||||
- **Subscription Management** - Easy subscribe/unsubscribe patterns
|
||||
|
||||
**Event Categories:**
|
||||
```typescript
|
||||
interface EventBusEvents {
|
||||
// User events
|
||||
'user:authenticated': { userId: string, profile: UserMetadata }
|
||||
'user:profile-updated': { userId: string, changes: Partial<UserMetadata> }
|
||||
'user:logout': { userId: string }
|
||||
|
||||
// Chat events
|
||||
'chat:message-received': { messageId: string, from: string, content: string }
|
||||
'chat:typing-start': { from: string, chatId: string }
|
||||
|
||||
// Payment events
|
||||
'payment:invoice-created': { invoiceId: string, amount: number }
|
||||
'payment:received': { invoiceId: string, amount: number }
|
||||
|
||||
// Relay events
|
||||
'relay:connected': { url: string }
|
||||
'relay:disconnected': { url: string, reason?: string }
|
||||
}
|
||||
```
|
||||
|
||||
**See:** [[../01-architecture/event-bus|📖 Event Bus Communication Documentation]]
|
||||
|
||||
## Service Lifecycle
|
||||
|
||||
### **Initialization Phase**
|
||||
Services are initialized in dependency order during application startup:
|
||||
|
||||
```typescript
|
||||
// 1. Base services (no dependencies)
|
||||
await authService.initialize()
|
||||
await storageService.initialize()
|
||||
|
||||
// 2. Infrastructure services (depend on base services)
|
||||
await relayHub.initialize()
|
||||
await toastService.initialize()
|
||||
|
||||
// 3. Feature services (depend on infrastructure)
|
||||
await chatService.initialize()
|
||||
await eventsService.initialize()
|
||||
```
|
||||
|
||||
### **Service Dependencies**
|
||||
Services declare their dependencies through constructor injection:
|
||||
|
||||
```typescript
|
||||
class ChatService extends BaseService {
|
||||
constructor(
|
||||
private auth = injectService(SERVICE_TOKENS.AUTH_SERVICE),
|
||||
private relayHub = injectService(SERVICE_TOKENS.RELAY_HUB),
|
||||
private storage = injectService(SERVICE_TOKENS.STORAGE_SERVICE),
|
||||
private eventBus = injectService(SERVICE_TOKENS.EVENT_BUS)
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Disposal Phase**
|
||||
Services are disposed in reverse dependency order during application shutdown:
|
||||
|
||||
```typescript
|
||||
async dispose(): Promise<void> {
|
||||
// Clean up subscriptions
|
||||
this.subscriptions.forEach(sub => sub.close())
|
||||
|
||||
// Save persistent state
|
||||
await this.storage.setUserData('chat:messages', this.messages.value)
|
||||
|
||||
// Mark as disposed
|
||||
this.isDisposed.value = true
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
### **Service Tokens**
|
||||
Type-safe service tokens prevent runtime errors and enable proper TypeScript inference:
|
||||
|
||||
```typescript
|
||||
export const SERVICE_TOKENS = {
|
||||
AUTH_SERVICE: Symbol('AUTH_SERVICE') as InjectionKey<AuthService>,
|
||||
RELAY_HUB: Symbol('RELAY_HUB') as InjectionKey<RelayHub>,
|
||||
STORAGE_SERVICE: Symbol('STORAGE_SERVICE') as InjectionKey<StorageService>,
|
||||
TOAST_SERVICE: Symbol('TOAST_SERVICE') as InjectionKey<ToastService>,
|
||||
} as const
|
||||
```
|
||||
|
||||
### **Service Registration**
|
||||
Services are registered during module installation:
|
||||
|
||||
```typescript
|
||||
// In base module installation
|
||||
export async function installBaseModule(app: App) {
|
||||
// Create service instances
|
||||
const authService = new AuthService()
|
||||
const relayHub = new RelayHub()
|
||||
const storageService = new StorageService()
|
||||
|
||||
// Register in container
|
||||
container.provide(SERVICE_TOKENS.AUTH_SERVICE, authService)
|
||||
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
|
||||
container.provide(SERVICE_TOKENS.STORAGE_SERVICE, storageService)
|
||||
|
||||
// Initialize services
|
||||
await authService.initialize()
|
||||
await relayHub.initialize()
|
||||
}
|
||||
```
|
||||
|
||||
### **Service Consumption**
|
||||
Services are injected where needed using type-safe injection:
|
||||
|
||||
```typescript
|
||||
// In composables
|
||||
export function useAuth() {
|
||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
||||
|
||||
return {
|
||||
user: authService.user,
|
||||
login: authService.login,
|
||||
logout: authService.logout
|
||||
}
|
||||
}
|
||||
|
||||
// In components
|
||||
<script setup>
|
||||
const toast = injectService(SERVICE_TOKENS.TOAST_SERVICE)
|
||||
const handleSuccess = () => toast.operation.success('Action completed!')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Service Development
|
||||
|
||||
### **Creating a New Service**
|
||||
|
||||
#### 1. Service Class Implementation
|
||||
```typescript
|
||||
// src/core/services/MyService.ts
|
||||
export class MyService extends BaseService {
|
||||
// Reactive state
|
||||
private readonly _data = ref<MyData[]>([])
|
||||
private readonly _isLoading = ref(false)
|
||||
|
||||
// Public readonly access to state
|
||||
public readonly data = readonly(this._data)
|
||||
public readonly isLoading = readonly(this._isLoading)
|
||||
|
||||
constructor(
|
||||
private dependency = injectService(SERVICE_TOKENS.DEPENDENCY)
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Initialization logic
|
||||
await this.loadInitialData()
|
||||
this.isInitialized.value = true
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
// Cleanup logic
|
||||
this._data.value = []
|
||||
this.isDisposed.value = true
|
||||
}
|
||||
|
||||
// Public methods
|
||||
async createItem(item: CreateItemRequest): Promise<MyData> {
|
||||
this._isLoading.value = true
|
||||
try {
|
||||
const newItem = await this.dependency.create(item)
|
||||
this._data.value.push(newItem)
|
||||
return newItem
|
||||
} finally {
|
||||
this._isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Service Token Registration
|
||||
```typescript
|
||||
// Add to SERVICE_TOKENS
|
||||
export const SERVICE_TOKENS = {
|
||||
// ... existing tokens
|
||||
MY_SERVICE: Symbol('MY_SERVICE') as InjectionKey<MyService>,
|
||||
} as const
|
||||
```
|
||||
|
||||
#### 3. Service Registration in Module
|
||||
```typescript
|
||||
// In module installation
|
||||
const myService = new MyService()
|
||||
container.provide(SERVICE_TOKENS.MY_SERVICE, myService)
|
||||
await myService.initialize()
|
||||
```
|
||||
|
||||
### **Service Best Practices**
|
||||
|
||||
#### **Reactive State Management**
|
||||
- Use `ref()` for mutable state, `readonly()` for public access
|
||||
- Provide computed properties for derived state
|
||||
- Use `watch()` and `watchEffect()` for side effects
|
||||
|
||||
#### **Error Handling**
|
||||
- Throw descriptive errors with proper types
|
||||
- Use try/catch blocks with proper cleanup
|
||||
- Log errors appropriately for debugging
|
||||
|
||||
#### **Performance Optimization**
|
||||
- Implement proper subscription cleanup in `dispose()`
|
||||
- Use debouncing for frequent operations
|
||||
- Cache expensive computations with `computed()`
|
||||
|
||||
## Testing Services
|
||||
|
||||
### **Service Unit Tests**
|
||||
```typescript
|
||||
// tests/unit/services/MyService.test.ts
|
||||
describe('MyService', () => {
|
||||
let service: MyService
|
||||
let mockDependency: MockType<DependencyService>
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mocks
|
||||
mockDependency = createMockService()
|
||||
|
||||
// Create service with mocks
|
||||
service = new MyService(mockDependency)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('should initialize correctly', async () => {
|
||||
await service.initialize()
|
||||
|
||||
expect(service.isInitialized.value).toBe(true)
|
||||
expect(service.data.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should create items', async () => {
|
||||
await service.initialize()
|
||||
|
||||
const item = await service.createItem({ name: 'test' })
|
||||
|
||||
expect(service.data.value).toContain(item)
|
||||
expect(mockDependency.create).toHaveBeenCalledWith({ name: 'test' })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### **Integration Tests**
|
||||
```typescript
|
||||
// tests/integration/services/ServiceIntegration.test.ts
|
||||
describe('Service Integration', () => {
|
||||
let container: DIContainer
|
||||
|
||||
beforeEach(async () => {
|
||||
container = createTestContainer()
|
||||
await installTestServices(container)
|
||||
})
|
||||
|
||||
it('should handle cross-service communication', async () => {
|
||||
const authService = container.get(SERVICE_TOKENS.AUTH_SERVICE)
|
||||
const chatService = container.get(SERVICE_TOKENS.CHAT_SERVICE)
|
||||
|
||||
await authService.login('test-key')
|
||||
const message = await chatService.sendMessage('Hello')
|
||||
|
||||
expect(message.author).toBe(authService.user.value?.pubkey)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
### Service Documentation
|
||||
- **[[authentication|🔐 Authentication Service]]** - User identity and session management
|
||||
- **[[storage-service|💾 Storage Service]]** - User-scoped data persistence
|
||||
- **[[toast-service|📢 Toast Service]]** - User notifications and feedback
|
||||
- **[[visibility-service|👁️ Visibility Service]]** - Dynamic UI component control
|
||||
|
||||
### Architecture References
|
||||
- **[[../01-architecture/dependency-injection|⚙️ Dependency Injection]]** - DI container system
|
||||
- **[[../01-architecture/event-bus|📡 Event Bus Communication]]** - Inter-service messaging
|
||||
- **[[../02-modules/index|📦 Module System]]** - How services integrate with modules
|
||||
- **[[../04-development/testing|🧪 Testing Guide]]** - Service testing patterns
|
||||
|
||||
---
|
||||
|
||||
**Tags:** #services #architecture #dependency-injection #reactive-state
|
||||
**Last Updated:** 2025-09-06
|
||||
**Author:** Development Team
|
||||
642
docs/03-core-services/visibility-service-integration.md
Normal file
642
docs/03-core-services/visibility-service-integration.md
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
# VisibilityService Integration Guide for Module Developers
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Basic Service Integration
|
||||
|
||||
```typescript
|
||||
// src/modules/your-module/services/your-service.ts
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
|
||||
export class YourService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'YourService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['VisibilityService'] // Optional but recommended
|
||||
}
|
||||
|
||||
private visibilityUnsubscribe?: () => void
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Your initialization code
|
||||
await this.setupService()
|
||||
|
||||
// Register with visibility service
|
||||
this.registerWithVisibilityService()
|
||||
}
|
||||
|
||||
private registerWithVisibilityService(): void {
|
||||
if (!this.visibilityService) {
|
||||
this.debug('VisibilityService not available')
|
||||
return
|
||||
}
|
||||
|
||||
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
||||
this.metadata.name,
|
||||
async () => this.handleAppResume(),
|
||||
async () => this.handleAppPause()
|
||||
)
|
||||
|
||||
this.debug('Registered with VisibilityService')
|
||||
}
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
this.debug('App resumed - checking connections')
|
||||
|
||||
// 1. Check if reconnection is needed
|
||||
if (await this.needsReconnection()) {
|
||||
await this.reconnectService()
|
||||
}
|
||||
|
||||
// 2. Resume normal operations
|
||||
this.resumeBackgroundTasks()
|
||||
}
|
||||
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.debug('App paused - reducing activity')
|
||||
|
||||
// 1. Stop non-essential tasks
|
||||
this.pauseBackgroundTasks()
|
||||
|
||||
// 2. Prepare for potential disconnection
|
||||
await this.prepareForPause()
|
||||
}
|
||||
|
||||
protected async onDispose(): Promise<void> {
|
||||
// Always clean up registration
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe()
|
||||
}
|
||||
|
||||
this.debug('Service disposed')
|
||||
}
|
||||
|
||||
// Implement these methods based on your service needs
|
||||
private async needsReconnection(): Promise<boolean> {
|
||||
// Check if your service connections are healthy
|
||||
return false
|
||||
}
|
||||
|
||||
private async reconnectService(): Promise<void> {
|
||||
// Reconnect your service
|
||||
}
|
||||
|
||||
private resumeBackgroundTasks(): void {
|
||||
// Resume periodic tasks, polling, etc.
|
||||
}
|
||||
|
||||
private pauseBackgroundTasks(): void {
|
||||
// Pause periodic tasks to save battery
|
||||
}
|
||||
|
||||
private async prepareForPause(): Promise<void> {
|
||||
// Save state, queue operations, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Patterns by Service Type
|
||||
|
||||
### Real-Time Connection Services (WebSocket, Nostr, etc.)
|
||||
|
||||
```typescript
|
||||
export class RealtimeService extends BaseService {
|
||||
private connections = new Map<string, Connection>()
|
||||
private subscriptions = new Map<string, Subscription>()
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
// 1. Check connection health
|
||||
const brokenConnections = await this.checkConnectionHealth()
|
||||
|
||||
// 2. Reconnect failed connections
|
||||
for (const connectionId of brokenConnections) {
|
||||
await this.reconnectConnection(connectionId)
|
||||
}
|
||||
|
||||
// 3. Restore subscriptions
|
||||
await this.restoreSubscriptions()
|
||||
|
||||
// 4. Resume heartbeat/keepalive
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
private async handleAppPause(): Promise<void> {
|
||||
// 1. Stop heartbeat to save battery
|
||||
this.stopHeartbeat()
|
||||
|
||||
// 2. Don't disconnect immediately (for quick resume)
|
||||
// Connections will be checked when app resumes
|
||||
}
|
||||
|
||||
private async checkConnectionHealth(): Promise<string[]> {
|
||||
const broken: string[] = []
|
||||
|
||||
for (const [id, connection] of this.connections) {
|
||||
if (!connection.isConnected()) {
|
||||
broken.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return broken
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Sync Services
|
||||
|
||||
```typescript
|
||||
export class DataSyncService extends BaseService {
|
||||
private syncQueue: Operation[] = []
|
||||
private lastSyncTime: number = 0
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
const hiddenTime = Date.now() - this.lastSyncTime
|
||||
|
||||
// If hidden for > 5 minutes, do full sync
|
||||
if (hiddenTime > 300000) {
|
||||
await this.performFullSync()
|
||||
} else {
|
||||
await this.performIncrementalSync()
|
||||
}
|
||||
|
||||
// Process queued operations
|
||||
await this.processQueue()
|
||||
|
||||
// Resume periodic sync
|
||||
this.startPeriodicSync()
|
||||
}
|
||||
|
||||
private async handleAppPause(): Promise<void> {
|
||||
// Stop periodic sync
|
||||
this.stopPeriodicSync()
|
||||
|
||||
// Save timestamp
|
||||
this.lastSyncTime = Date.now()
|
||||
|
||||
// Enable operation queueing
|
||||
this.enableQueueMode()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Background Processing Services
|
||||
|
||||
```typescript
|
||||
export class BackgroundService extends BaseService {
|
||||
private processingInterval?: number
|
||||
private taskQueue: Task[] = []
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
// Resume background processing
|
||||
this.startProcessing()
|
||||
|
||||
// Process any queued tasks
|
||||
await this.processQueuedTasks()
|
||||
}
|
||||
|
||||
private async handleAppPause(): Promise<void> {
|
||||
// Stop background processing to save CPU/battery
|
||||
if (this.processingInterval) {
|
||||
clearInterval(this.processingInterval)
|
||||
this.processingInterval = undefined
|
||||
}
|
||||
|
||||
// Queue new tasks instead of processing immediately
|
||||
this.enableTaskQueueing()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Module Registration Pattern
|
||||
|
||||
### Module Index File
|
||||
|
||||
```typescript
|
||||
// src/modules/your-module/index.ts
|
||||
import type { App } from 'vue'
|
||||
import type { ModulePlugin } from '@/core/types'
|
||||
import { YourService } from './services/your-service'
|
||||
|
||||
export const yourModule: ModulePlugin = {
|
||||
name: 'your-module',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'], // base module provides VisibilityService
|
||||
|
||||
async install(app: App, options?: any) {
|
||||
console.log('🔧 Installing your module...')
|
||||
|
||||
// Create and initialize service
|
||||
const yourService = new YourService()
|
||||
|
||||
// Initialize service (this will register with VisibilityService)
|
||||
await yourService.initialize({
|
||||
waitForDependencies: true, // Wait for VisibilityService
|
||||
maxRetries: 3
|
||||
})
|
||||
|
||||
// Register service in DI container
|
||||
container.provide(YOUR_SERVICE_TOKEN, yourService)
|
||||
|
||||
console.log('✅ Your module installed successfully')
|
||||
},
|
||||
|
||||
async uninstall() {
|
||||
console.log('🗑️ Uninstalling your module...')
|
||||
// Services will auto-dispose and unregister from VisibilityService
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
### ✅ Do's
|
||||
|
||||
- **Always register during `onInitialize()`**
|
||||
```typescript
|
||||
protected async onInitialize(): Promise<void> {
|
||||
await this.setupService()
|
||||
this.registerWithVisibilityService() // ✅
|
||||
}
|
||||
```
|
||||
|
||||
- **Check hidden duration before expensive operations**
|
||||
```typescript
|
||||
private async handleAppResume(): Promise<void> {
|
||||
const state = this.visibilityService.getState()
|
||||
if (state.hiddenDuration && state.hiddenDuration > 30000) {
|
||||
await this.performFullReconnect() // ✅ Only if needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Always clean up registrations**
|
||||
```typescript
|
||||
protected async onDispose(): Promise<void> {
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe() // ✅
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Use graceful pause strategies**
|
||||
```typescript
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.stopHeartbeat() // ✅ Stop periodic tasks
|
||||
// Keep connections alive for quick resume
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don'ts
|
||||
|
||||
- **Don't immediately disconnect on pause**
|
||||
```typescript
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.disconnectAll() // ❌ Too aggressive
|
||||
}
|
||||
```
|
||||
|
||||
- **Don't ignore the service availability check**
|
||||
```typescript
|
||||
private registerWithVisibilityService(): void {
|
||||
// ❌ Missing availability check
|
||||
this.visibilityService.registerService(/*...*/)
|
||||
|
||||
// ✅ Correct
|
||||
if (!this.visibilityService) return
|
||||
this.visibilityService.registerService(/*...*/)
|
||||
}
|
||||
```
|
||||
|
||||
- **Don't forget dependencies in metadata**
|
||||
```typescript
|
||||
protected readonly metadata = {
|
||||
name: 'MyService',
|
||||
dependencies: [] // ❌ Should include 'VisibilityService'
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Connection Health Checking
|
||||
|
||||
```typescript
|
||||
private async checkConnectionHealth(): Promise<boolean> {
|
||||
try {
|
||||
// Perform a lightweight health check
|
||||
await this.ping()
|
||||
return true
|
||||
} catch (error) {
|
||||
this.debug('Connection health check failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription Restoration
|
||||
|
||||
```typescript
|
||||
private async restoreSubscriptions(): Promise<void> {
|
||||
const subscriptionsToRestore = Array.from(this.subscriptionConfigs.values())
|
||||
|
||||
for (const config of subscriptionsToRestore) {
|
||||
try {
|
||||
await this.recreateSubscription(config)
|
||||
} catch (error) {
|
||||
this.debug(`Failed to restore subscription ${config.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Operation Queueing
|
||||
|
||||
```typescript
|
||||
private operationQueue: Operation[] = []
|
||||
private queueingEnabled = false
|
||||
|
||||
private async executeOrQueue(operation: Operation): Promise<void> {
|
||||
if (this.queueingEnabled) {
|
||||
this.operationQueue.push(operation)
|
||||
} else {
|
||||
await operation.execute()
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
const operations = this.operationQueue.splice(0) // Clear queue
|
||||
|
||||
for (const operation of operations) {
|
||||
try {
|
||||
await operation.execute()
|
||||
} catch (error) {
|
||||
this.debug('Queued operation failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Integration
|
||||
|
||||
### Mock VisibilityService for Tests
|
||||
|
||||
```typescript
|
||||
// tests/setup/mockVisibilityService.ts
|
||||
export const createMockVisibilityService = () => ({
|
||||
isVisible: { value: true },
|
||||
isOnline: { value: true },
|
||||
registerService: vi.fn(() => vi.fn()), // Returns unregister function
|
||||
getState: vi.fn(() => ({
|
||||
isVisible: true,
|
||||
isOnline: true,
|
||||
hiddenDuration: 0
|
||||
}))
|
||||
})
|
||||
|
||||
// In your test
|
||||
describe('YourService', () => {
|
||||
it('should register with VisibilityService', async () => {
|
||||
const mockVisibility = createMockVisibilityService()
|
||||
const service = new YourService()
|
||||
service.visibilityService = mockVisibility
|
||||
|
||||
await service.initialize()
|
||||
|
||||
expect(mockVisibility.registerService).toHaveBeenCalledWith(
|
||||
'YourService',
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Test Visibility Events
|
||||
|
||||
```typescript
|
||||
it('should handle app resume correctly', async () => {
|
||||
const service = new YourService()
|
||||
const reconnectSpy = vi.spyOn(service, 'reconnect')
|
||||
|
||||
// Simulate app resume after long pause
|
||||
await service.handleAppResume()
|
||||
|
||||
expect(reconnectSpy).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
## WebSocket Connection Recovery Examples
|
||||
|
||||
### Real-World Chat Message Recovery
|
||||
|
||||
**Scenario**: User gets a phone call, returns to app 5 minutes later. Chat messages arrived while away.
|
||||
|
||||
```typescript
|
||||
export class ChatService extends BaseService {
|
||||
private async handleAppResume(): Promise<void> {
|
||||
// Step 1: Check if subscription still exists
|
||||
if (!this.subscriptionUnsubscriber) {
|
||||
this.debug('Chat subscription lost during backgrounding - recreating')
|
||||
this.setupMessageSubscription()
|
||||
}
|
||||
|
||||
// Step 2: Sync missed messages from all chat peers
|
||||
await this.syncMissedMessages()
|
||||
}
|
||||
|
||||
private async syncMissedMessages(): Promise<void> {
|
||||
const peers = Array.from(this.peers.value.values())
|
||||
|
||||
for (const peer of peers) {
|
||||
try {
|
||||
// Get messages from last hour for this peer
|
||||
const recentEvents = await this.relayHub.queryEvents([
|
||||
{
|
||||
kinds: [4], // Encrypted DMs
|
||||
authors: [peer.pubkey],
|
||||
'#p': [this.getUserPubkey()],
|
||||
since: Math.floor(Date.now() / 1000) - 3600,
|
||||
limit: 20
|
||||
}
|
||||
])
|
||||
|
||||
// Process each recovered message
|
||||
for (const event of recentEvents) {
|
||||
await this.processIncomingMessage(event)
|
||||
}
|
||||
|
||||
this.debug(`Recovered ${recentEvents.length} messages from ${peer.pubkey.slice(0, 8)}`)
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Failed to recover messages from peer ${peer.pubkey.slice(0, 8)}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nostr Relay Connection Recovery
|
||||
|
||||
**Scenario**: Nostr relays disconnected due to mobile browser suspension. Subscriptions need restoration.
|
||||
|
||||
```typescript
|
||||
export class RelayHub extends BaseService {
|
||||
private async handleResume(): Promise<void> {
|
||||
// Step 1: Check which relays are still connected
|
||||
const disconnectedRelays = this.checkDisconnectedRelays()
|
||||
|
||||
if (disconnectedRelays.length > 0) {
|
||||
this.debug(`Found ${disconnectedRelays.length} disconnected relays`)
|
||||
await this.reconnectToRelays(disconnectedRelays)
|
||||
}
|
||||
|
||||
// Step 2: Restore all subscriptions on recovered relays
|
||||
await this.restoreSubscriptions()
|
||||
|
||||
this.emit('connectionRecovered', {
|
||||
reconnectedRelays: disconnectedRelays.length,
|
||||
restoredSubscriptions: this.subscriptions.size
|
||||
})
|
||||
}
|
||||
|
||||
private async restoreSubscriptions(): Promise<void> {
|
||||
if (this.subscriptions.size === 0) return
|
||||
|
||||
this.debug(`Restoring ${this.subscriptions.size} subscriptions`)
|
||||
|
||||
for (const [id, config] of this.subscriptions) {
|
||||
try {
|
||||
// Recreate subscription on available relays
|
||||
const subscription = this.pool.subscribeMany(
|
||||
this.getAvailableRelays(),
|
||||
config.filters,
|
||||
{
|
||||
onevent: (event) => this.emit('event', { subscriptionId: id, event }),
|
||||
oneose: () => this.emit('eose', { subscriptionId: id })
|
||||
}
|
||||
)
|
||||
|
||||
this.activeSubscriptions.set(id, subscription)
|
||||
this.debug(`✅ Restored subscription: ${id}`)
|
||||
|
||||
} catch (error) {
|
||||
this.debug(`❌ Failed to restore subscription ${id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Service Recovery
|
||||
|
||||
**Scenario**: Custom WebSocket service (like nostrclient-hub) needs reconnection after suspension.
|
||||
|
||||
```typescript
|
||||
export class WebSocketService extends BaseService {
|
||||
private ws: WebSocket | null = null
|
||||
private subscriptions = new Map<string, any>()
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
// Step 1: Check WebSocket connection state
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.debug('WebSocket connection lost, reconnecting...')
|
||||
await this.reconnect()
|
||||
}
|
||||
|
||||
// Step 2: Resubscribe to all active subscriptions
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
await this.resubscribeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(this.config.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.debug('WebSocket reconnected successfully')
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.debug('WebSocket reconnection failed:', error)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
this.ws.onmessage = (message) => {
|
||||
this.handleMessage(JSON.parse(message.data))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async resubscribeAll(): Promise<void> {
|
||||
for (const [id, config] of this.subscriptions) {
|
||||
const subscribeMessage = JSON.stringify(['REQ', id, ...config.filters])
|
||||
this.ws?.send(subscribeMessage)
|
||||
this.debug(`Resubscribed to: ${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Connection Issues
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```typescript
|
||||
// In browser console or service initialization
|
||||
localStorage.setItem('debug', 'VisibilityService,RelayHub,ChatService')
|
||||
|
||||
// Or programmatically in your service
|
||||
protected async onInitialize(): Promise<void> {
|
||||
if (import.meta.env.DEV) {
|
||||
this.visibilityService.on('debug', (message, data) => {
|
||||
console.log(`[VisibilityService] ${message}`, data)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Check Connection Status
|
||||
|
||||
```javascript
|
||||
// In browser console
|
||||
// Check visibility state
|
||||
console.log('Visibility state:', visibilityService.getState())
|
||||
|
||||
// Check relay connections
|
||||
console.log('Relay status:', relayHub.getConnectionStatus())
|
||||
|
||||
// Check active subscriptions
|
||||
console.log('Active subscriptions:', relayHub.subscriptionDetails)
|
||||
|
||||
// Force connection check
|
||||
await visibilityService.forceConnectionCheck()
|
||||
```
|
||||
|
||||
### Monitor Recovery Events
|
||||
|
||||
```typescript
|
||||
export class MyService extends BaseService {
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Listen for visibility events
|
||||
this.visibilityService.on('visibilityChanged', (isVisible) => {
|
||||
console.log('App visibility changed:', isVisible)
|
||||
})
|
||||
|
||||
// Listen for reconnection events
|
||||
this.relayHub.on('connectionRecovered', (data) => {
|
||||
console.log('Connections recovered:', data)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
This integration guide provides everything a module developer needs to add visibility management to their services. The patterns are battle-tested and optimize for both user experience and device battery life.
|
||||
|
||||
**Key takeaway**: Mobile browsers WILL suspend WebSocket connections when apps lose focus. Integrating with VisibilityService ensures your real-time features work reliably across all platforms and usage patterns.
|
||||
912
docs/03-core-services/visibility-service.md
Normal file
912
docs/03-core-services/visibility-service.md
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
# VisibilityService Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The `VisibilityService` is a centralized service that monitors app visibility state and coordinates connection recovery across all modules. It's designed to optimize battery life on mobile devices while ensuring reliable reconnections when the app becomes visible again.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [Architecture](#architecture)
|
||||
- [API Reference](#api-reference)
|
||||
- [Integration Guide](#integration-guide)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Mobile Optimization](#mobile-optimization)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Visibility States
|
||||
|
||||
The service tracks multiple visibility-related states:
|
||||
|
||||
```typescript
|
||||
interface VisibilityState {
|
||||
isVisible: boolean // Document is visible and focused
|
||||
isOnline: boolean // Network connectivity status
|
||||
lastHiddenAt: number // Timestamp when app was hidden
|
||||
lastVisibleAt: number // Timestamp when app became visible
|
||||
hiddenDuration: number // How long the app was hidden (ms)
|
||||
}
|
||||
```
|
||||
|
||||
### Reconnection Strategy
|
||||
|
||||
The service uses intelligent thresholds to determine when reconnection is needed:
|
||||
|
||||
- **Reconnection Threshold**: 30 seconds (configurable)
|
||||
- **Debounce Delay**: 100ms for rapid visibility changes
|
||||
- **Health Check Interval**: 5 seconds when visible
|
||||
- **Pause Delay**: 5 seconds before pausing services
|
||||
|
||||
### Service Registration
|
||||
|
||||
Services register callbacks for pause/resume operations:
|
||||
|
||||
```typescript
|
||||
const unregister = visibilityService.registerService(
|
||||
'ServiceName',
|
||||
async () => handleResume(), // Called when app becomes visible
|
||||
async () => handlePause() // Called when app becomes hidden
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
VS[VisibilityService] --> |monitors| DOM[Document Events]
|
||||
VS --> |monitors| WIN[Window Events]
|
||||
VS --> |monitors| NET[Network Events]
|
||||
|
||||
VS --> |manages| RH[RelayHub]
|
||||
VS --> |manages| CS[ChatService]
|
||||
VS --> |manages| OTHER[Other Services]
|
||||
|
||||
RH --> |reconnects| RELAYS[Nostr Relays]
|
||||
RH --> |restores| SUBS[Subscriptions]
|
||||
```
|
||||
|
||||
### Event Flow
|
||||
|
||||
1. **App becomes hidden** → Stop health checks → Schedule pause (5s delay)
|
||||
2. **App becomes visible** → Calculate hidden duration → Resume services if needed
|
||||
3. **Network offline** → Immediately pause all services
|
||||
4. **Network online** → Resume all services if app is visible
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### VisibilityService Class
|
||||
|
||||
#### Properties
|
||||
|
||||
```typescript
|
||||
// Reactive state (read-only)
|
||||
readonly isVisible: ComputedRef<boolean>
|
||||
readonly isOnline: ComputedRef<boolean>
|
||||
readonly isPaused: ComputedRef<boolean>
|
||||
readonly lastHiddenAt: ComputedRef<number | null>
|
||||
readonly lastVisibleAt: ComputedRef<number | null>
|
||||
readonly hiddenDuration: ComputedRef<number | null>
|
||||
readonly needsReconnection: ComputedRef<boolean>
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
```typescript
|
||||
// Service registration
|
||||
registerService(
|
||||
name: string,
|
||||
onResume: () => Promise<void>,
|
||||
onPause: () => Promise<void>
|
||||
): () => void
|
||||
|
||||
// Manual control
|
||||
forceConnectionCheck(): Promise<void>
|
||||
getState(): VisibilityState
|
||||
```
|
||||
|
||||
### BaseService Integration
|
||||
|
||||
All services extending `BaseService` automatically have access to `visibilityService`:
|
||||
|
||||
```typescript
|
||||
export class MyService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'MyService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['VisibilityService'] // Optional: declare dependency
|
||||
}
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Register for visibility management
|
||||
this.registerWithVisibilityService()
|
||||
}
|
||||
|
||||
private registerWithVisibilityService(): void {
|
||||
if (!this.visibilityService) return
|
||||
|
||||
this.visibilityService.registerService(
|
||||
'MyService',
|
||||
async () => this.handleResume(),
|
||||
async () => this.handlePause()
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Guide
|
||||
|
||||
### Step 1: Service Registration
|
||||
|
||||
Register your service during initialization:
|
||||
|
||||
```typescript
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Your service initialization code
|
||||
await this.setupConnections()
|
||||
|
||||
// Register with visibility service
|
||||
this.visibilityUnsubscribe = this.visibilityService?.registerService(
|
||||
this.metadata.name,
|
||||
async () => this.handleAppResume(),
|
||||
async () => this.handleAppPause()
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Implement Resume Handler
|
||||
|
||||
Handle app resuming (visibility restored):
|
||||
|
||||
```typescript
|
||||
private async handleAppResume(): Promise<void> {
|
||||
this.debug('App resumed, checking connections')
|
||||
|
||||
// 1. Check connection health
|
||||
const needsReconnection = await this.checkConnectionHealth()
|
||||
|
||||
// 2. Reconnect if necessary
|
||||
if (needsReconnection) {
|
||||
await this.reconnect()
|
||||
}
|
||||
|
||||
// 3. Restore any lost subscriptions
|
||||
await this.restoreSubscriptions()
|
||||
|
||||
// 4. Resume normal operations
|
||||
this.startBackgroundTasks()
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement Pause Handler
|
||||
|
||||
Handle app pausing (visibility lost):
|
||||
|
||||
```typescript
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.debug('App paused, reducing activity')
|
||||
|
||||
// 1. Stop non-essential background tasks
|
||||
this.stopBackgroundTasks()
|
||||
|
||||
// 2. Reduce connection activity (don't disconnect immediately)
|
||||
this.reduceConnectionActivity()
|
||||
|
||||
// 3. Save any pending state
|
||||
await this.saveCurrentState()
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Cleanup
|
||||
|
||||
Unregister when service is disposed:
|
||||
|
||||
```typescript
|
||||
protected async onDispose(): Promise<void> {
|
||||
// Unregister from visibility service
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe()
|
||||
this.visibilityUnsubscribe = undefined
|
||||
}
|
||||
|
||||
// Other cleanup...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
```typescript
|
||||
// ✅ Register during service initialization
|
||||
protected async onInitialize(): Promise<void> {
|
||||
this.registerWithVisibilityService()
|
||||
}
|
||||
|
||||
// ✅ Check connection health before resuming
|
||||
private async handleAppResume(): Promise<void> {
|
||||
if (await this.needsReconnection()) {
|
||||
await this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Graceful pause - don't immediately disconnect
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.stopHealthChecks() // Stop periodic tasks
|
||||
// Keep connections alive for quick resume
|
||||
}
|
||||
|
||||
// ✅ Handle network events separately
|
||||
private async handleNetworkChange(isOnline: boolean): Promise<void> {
|
||||
if (isOnline) {
|
||||
await this.forceReconnection()
|
||||
} else {
|
||||
this.pauseNetworkOperations()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Store subscription configurations for restoration
|
||||
private subscriptionConfigs = new Map<string, SubscriptionConfig>()
|
||||
```
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
```typescript
|
||||
// ❌ Don't immediately disconnect on pause
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.disconnect() // Too aggressive for quick tab switches
|
||||
}
|
||||
|
||||
// ❌ Don't ignore hidden duration
|
||||
private async handleAppResume(): Promise<void> {
|
||||
await this.reconnect() // Should check if reconnection is needed
|
||||
}
|
||||
|
||||
// ❌ Don't handle visibility changes without debouncing
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.handleVisibilityChange() // Can fire rapidly
|
||||
})
|
||||
|
||||
// ❌ Don't forget to clean up registrations
|
||||
protected async onDispose(): Promise<void> {
|
||||
// Missing: this.visibilityUnsubscribe?.()
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
```typescript
|
||||
class OptimizedService extends BaseService {
|
||||
private connectionHealthCache = new Map<string, {
|
||||
isHealthy: boolean,
|
||||
lastChecked: number
|
||||
}>()
|
||||
|
||||
private async checkConnectionHealth(): Promise<boolean> {
|
||||
const now = Date.now()
|
||||
const cached = this.connectionHealthCache.get('main')
|
||||
|
||||
// Use cached result if recent (within 5 seconds)
|
||||
if (cached && (now - cached.lastChecked) < 5000) {
|
||||
return cached.isHealthy
|
||||
}
|
||||
|
||||
// Perform actual health check
|
||||
const isHealthy = await this.performHealthCheck()
|
||||
this.connectionHealthCache.set('main', { isHealthy, lastChecked: now })
|
||||
|
||||
return isHealthy
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Optimization
|
||||
|
||||
### Battery Life Considerations
|
||||
|
||||
The service optimizes for mobile battery life:
|
||||
|
||||
```typescript
|
||||
// Configurable thresholds for different platforms
|
||||
const MOBILE_CONFIG = {
|
||||
reconnectThreshold: 30000, // 30s before reconnection needed
|
||||
debounceDelay: 100, // 100ms debounce for rapid changes
|
||||
healthCheckInterval: 5000, // 5s health checks when visible
|
||||
pauseDelay: 5000 // 5s delay before pausing
|
||||
}
|
||||
|
||||
const DESKTOP_CONFIG = {
|
||||
reconnectThreshold: 60000, // 60s (desktop tabs stay connected longer)
|
||||
debounceDelay: 50, // 50ms (faster response)
|
||||
healthCheckInterval: 3000, // 3s (more frequent checks)
|
||||
pauseDelay: 10000 // 10s (longer delay before pausing)
|
||||
}
|
||||
```
|
||||
|
||||
### Browser-Specific Handling
|
||||
|
||||
```typescript
|
||||
// iOS Safari specific events
|
||||
window.addEventListener('pageshow', () => this.handleAppVisible())
|
||||
window.addEventListener('pagehide', () => this.handleAppHidden())
|
||||
|
||||
// Standard visibility API (all modern browsers)
|
||||
document.addEventListener('visibilitychange', this.visibilityHandler)
|
||||
|
||||
// Desktop focus handling
|
||||
window.addEventListener('focus', this.focusHandler)
|
||||
window.addEventListener('blur', this.blurHandler)
|
||||
|
||||
// Network status
|
||||
window.addEventListener('online', this.onlineHandler)
|
||||
window.addEventListener('offline', this.offlineHandler)
|
||||
```
|
||||
|
||||
### PWA/Standalone App Handling
|
||||
|
||||
```typescript
|
||||
// Detect if running as standalone PWA
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|
||||
// Adjust behavior for standalone apps
|
||||
const config = isStandalone ? {
|
||||
...MOBILE_CONFIG,
|
||||
reconnectThreshold: 15000, // Shorter threshold for PWAs
|
||||
healthCheckInterval: 2000 // More frequent checks for better UX
|
||||
} : MOBILE_CONFIG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### RelayHub Integration
|
||||
|
||||
```typescript
|
||||
export class RelayHub extends BaseService {
|
||||
private subscriptions = new Map<string, SubscriptionConfig>()
|
||||
private activeSubscriptions = new Map<string, any>()
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Initialize connections
|
||||
await this.connect()
|
||||
this.startHealthCheck()
|
||||
|
||||
// Register with visibility service
|
||||
this.registerWithVisibilityService()
|
||||
}
|
||||
|
||||
private async handleResume(): Promise<void> {
|
||||
this.debug('Handling resume from visibility change')
|
||||
|
||||
// Check which relays disconnected
|
||||
const disconnectedRelays = this.checkDisconnectedRelays()
|
||||
|
||||
if (disconnectedRelays.length > 0) {
|
||||
this.debug(`Found ${disconnectedRelays.length} disconnected relays`)
|
||||
await this.reconnectToRelays(disconnectedRelays)
|
||||
}
|
||||
|
||||
// Restore all subscriptions
|
||||
await this.restoreSubscriptions()
|
||||
|
||||
// Resume health check
|
||||
this.startHealthCheck()
|
||||
}
|
||||
|
||||
private async handlePause(): Promise<void> {
|
||||
this.debug('Handling pause from visibility change')
|
||||
|
||||
// Stop health check while paused (saves battery)
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval)
|
||||
this.healthCheckInterval = undefined
|
||||
}
|
||||
|
||||
// Don't disconnect immediately - connections will be verified on resume
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Chat Service Integration - WebSocket Connection Recovery
|
||||
|
||||
**Real-World Scenario**: User receives a WhatsApp notification, switches to WhatsApp for 2 minutes, then returns to the Nostr chat app. The WebSocket connection was suspended by the mobile browser.
|
||||
|
||||
```typescript
|
||||
export class ChatService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'ChatService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['RelayHub', 'AuthService', 'VisibilityService']
|
||||
}
|
||||
|
||||
private subscriptionUnsubscriber?: () => void
|
||||
private visibilityUnsubscribe?: () => void
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Set up chat subscription and register with visibility service
|
||||
await this.initializeMessageHandling()
|
||||
this.registerWithVisibilityService()
|
||||
|
||||
this.debug('Chat service fully initialized!')
|
||||
}
|
||||
|
||||
private registerWithVisibilityService(): void {
|
||||
if (!this.visibilityService) return
|
||||
|
||||
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
||||
this.metadata.name,
|
||||
async () => this.handleAppResume(),
|
||||
async () => this.handleAppPause()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 1: App becomes visible again
|
||||
* VisibilityService detects visibility change and calls this method
|
||||
*/
|
||||
private async handleAppResume(): Promise<void> {
|
||||
this.debug('App resumed - checking chat connections')
|
||||
|
||||
// Check if our chat subscription is still active
|
||||
if (!this.subscriptionUnsubscriber) {
|
||||
this.debug('Chat subscription lost, re-establishing...')
|
||||
this.setupMessageSubscription() // Recreate subscription
|
||||
}
|
||||
|
||||
// Sync any messages missed while app was hidden
|
||||
await this.syncMissedMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 2: Sync missed messages from the time we were away
|
||||
*/
|
||||
private async syncMissedMessages(): Promise<void> {
|
||||
try {
|
||||
const peers = Array.from(this.peers.value.values())
|
||||
const syncPromises = peers.map(peer => this.loadRecentMessagesForPeer(peer.pubkey))
|
||||
|
||||
await Promise.allSettled(syncPromises)
|
||||
this.debug('Missed messages sync completed')
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync missed messages:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 3: Load recent messages for each chat peer
|
||||
*/
|
||||
private async loadRecentMessagesForPeer(peerPubkey: string): Promise<void> {
|
||||
const userPubkey = this.authService?.user?.value?.pubkey
|
||||
if (!userPubkey || !this.relayHub) return
|
||||
|
||||
try {
|
||||
// Get messages from the last hour (while we were away)
|
||||
const oneHourAgo = Math.floor(Date.now() / 1000) - 3600
|
||||
|
||||
const events = await this.relayHub.queryEvents([
|
||||
{
|
||||
kinds: [4], // Encrypted DMs
|
||||
authors: [peerPubkey],
|
||||
'#p': [userPubkey],
|
||||
since: oneHourAgo,
|
||||
limit: 10
|
||||
},
|
||||
{
|
||||
kinds: [4], // Encrypted DMs from us to them
|
||||
authors: [userPubkey],
|
||||
'#p': [peerPubkey],
|
||||
since: oneHourAgo,
|
||||
limit: 10
|
||||
}
|
||||
])
|
||||
|
||||
// Process any new messages we missed
|
||||
for (const event of events) {
|
||||
await this.processIncomingMessage(event)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.debug(`Failed to load recent messages for peer ${peerPubkey.slice(0, 8)}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 4: Process messages (decrypt, filter market messages, add to chat)
|
||||
*/
|
||||
private async processIncomingMessage(event: any): Promise<void> {
|
||||
try {
|
||||
const userPubkey = this.authService?.user?.value?.pubkey
|
||||
const userPrivkey = this.authService?.user?.value?.prvkey
|
||||
|
||||
if (!userPubkey || !userPrivkey) return
|
||||
|
||||
const senderPubkey = event.pubkey
|
||||
const decryptedContent = await nip04.decrypt(userPrivkey, senderPubkey, event.content)
|
||||
|
||||
// Check if this is a market order (type 1 or 2)
|
||||
let isMarketMessage = false
|
||||
try {
|
||||
const parsedContent = JSON.parse(decryptedContent)
|
||||
if (parsedContent.type === 1 || parsedContent.type === 2) {
|
||||
isMarketMessage = true
|
||||
if (this.marketMessageHandler) {
|
||||
await this.marketMessageHandler(event)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, treat as regular chat message
|
||||
}
|
||||
|
||||
// Add to chat if it's not a market message
|
||||
if (!isMarketMessage) {
|
||||
const message: ChatMessage = {
|
||||
id: event.id,
|
||||
content: decryptedContent,
|
||||
created_at: event.created_at,
|
||||
sent: false,
|
||||
pubkey: senderPubkey
|
||||
}
|
||||
|
||||
this.addPeer(senderPubkey) // Ensure peer exists
|
||||
this.addMessage(senderPubkey, message) // Add to chat history
|
||||
|
||||
console.log('💬 Recovered missed message from:', senderPubkey.slice(0, 8))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to process recovered message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Battery-conscious pause behavior
|
||||
*/
|
||||
private async handleAppPause(): Promise<void> {
|
||||
this.debug('App paused - chat subscription maintained for quick resume')
|
||||
|
||||
// Don't immediately unsubscribe - RelayHub will handle connection management
|
||||
// This allows for quick resume without full subscription recreation overhead
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when service is disposed
|
||||
*/
|
||||
protected async onDispose(): Promise<void> {
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe()
|
||||
}
|
||||
|
||||
if (this.subscriptionUnsubscriber) {
|
||||
this.subscriptionUnsubscriber()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**What happens in this example:**
|
||||
|
||||
1. **🔍 Detection**: VisibilityService detects app became visible after 2+ minutes
|
||||
2. **🔌 Connection Check**: ChatService checks if its subscription is still active
|
||||
3. **📥 Message Recovery**: Queries for missed messages from all chat peers in the last hour
|
||||
4. **🔓 Decryption**: Decrypts and processes each missed message
|
||||
5. **📱 UI Update**: New messages appear in chat history as if they were never missed
|
||||
6. **⚡ Real-time Resume**: Chat subscription is fully restored for new incoming messages
|
||||
|
||||
**The user experience**: Seamless. Messages that arrived while the app was backgrounded appear instantly when the app regains focus.
|
||||
|
||||
### Custom Service Example
|
||||
|
||||
```typescript
|
||||
export class DataSyncService extends BaseService {
|
||||
private syncQueue: SyncOperation[] = []
|
||||
private lastSyncTimestamp: number = 0
|
||||
|
||||
protected readonly metadata = {
|
||||
name: 'DataSyncService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['VisibilityService', 'RelayHub']
|
||||
}
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
this.registerWithVisibilityService()
|
||||
this.startPeriodicSync()
|
||||
}
|
||||
|
||||
private registerWithVisibilityService(): void {
|
||||
if (!this.visibilityService) {
|
||||
this.debug('VisibilityService not available')
|
||||
return
|
||||
}
|
||||
|
||||
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
||||
'DataSyncService',
|
||||
async () => this.handleAppResume(),
|
||||
async () => this.handleAppPause()
|
||||
)
|
||||
}
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
const hiddenDuration = Date.now() - this.lastSyncTimestamp
|
||||
|
||||
// If we were hidden for more than 5 minutes, do a full sync
|
||||
if (hiddenDuration > 300000) {
|
||||
await this.performFullSync()
|
||||
} else {
|
||||
// Otherwise just sync changes since we paused
|
||||
await this.performIncrementalSync()
|
||||
}
|
||||
|
||||
// Process any queued sync operations
|
||||
await this.processSyncQueue()
|
||||
|
||||
// Resume periodic sync
|
||||
this.startPeriodicSync()
|
||||
}
|
||||
|
||||
private async handleAppPause(): Promise<void> {
|
||||
// Stop periodic sync to save battery
|
||||
this.stopPeriodicSync()
|
||||
|
||||
// Queue any pending operations instead of executing immediately
|
||||
this.enableOperationQueueing()
|
||||
|
||||
// Save current sync state
|
||||
this.lastSyncTimestamp = Date.now()
|
||||
await this.saveSyncState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Services Not Resuming
|
||||
|
||||
```typescript
|
||||
// Problem: Service not registered properly
|
||||
// Solution: Check registration in onInitialize()
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// ❌ Incorrect - missing registration
|
||||
await this.setupService()
|
||||
|
||||
// ✅ Correct - register with visibility service
|
||||
await this.setupService()
|
||||
this.registerWithVisibilityService()
|
||||
}
|
||||
```
|
||||
|
||||
#### Excessive Reconnections
|
||||
|
||||
```typescript
|
||||
// Problem: Not checking hidden duration
|
||||
// Solution: Implement proper threshold checking
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
// ❌ Incorrect - always reconnects
|
||||
await this.reconnect()
|
||||
|
||||
// ✅ Correct - check if reconnection is needed
|
||||
const state = this.visibilityService.getState()
|
||||
if (state.hiddenDuration && state.hiddenDuration > 30000) {
|
||||
await this.reconnect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### WebSocket Connection Issues
|
||||
|
||||
```typescript
|
||||
// Problem: WebSocket connections not recovering after app backgrounding
|
||||
// Common on mobile browsers (iOS Safari, Chrome mobile)
|
||||
|
||||
// ❌ Incorrect - not integrating with VisibilityService
|
||||
export class MyRealtimeService extends BaseService {
|
||||
private ws: WebSocket | null = null
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
this.ws = new WebSocket('wss://example.com')
|
||||
// Missing: visibility service registration
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct - proper WebSocket recovery integration
|
||||
export class MyRealtimeService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'MyRealtimeService',
|
||||
dependencies: ['VisibilityService']
|
||||
}
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private visibilityUnsubscribe?: () => void
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
await this.connect()
|
||||
this.registerWithVisibilityService() // ✅ Essential for mobile
|
||||
}
|
||||
|
||||
private registerWithVisibilityService(): void {
|
||||
if (!this.visibilityService) return
|
||||
|
||||
this.visibilityUnsubscribe = this.visibilityService.registerService(
|
||||
this.metadata.name,
|
||||
async () => this.handleAppResume(),
|
||||
async () => this.handleAppPause()
|
||||
)
|
||||
}
|
||||
|
||||
private async handleAppResume(): Promise<void> {
|
||||
// Check WebSocket connection health
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.debug('WebSocket disconnected, reconnecting...')
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
// Restore any lost subscriptions
|
||||
await this.restoreSubscriptions()
|
||||
}
|
||||
|
||||
private async handleAppPause(): Promise<void> {
|
||||
// Don't immediately close WebSocket
|
||||
// Mobile browsers may suspend it anyway
|
||||
this.debug('App paused - WebSocket will be checked on resume')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Mobile WebSocket Behavior:**
|
||||
- **iOS Safari**: Suspends WebSocket connections after ~30 seconds in background
|
||||
- **Chrome Mobile**: May suspend connections when memory is needed
|
||||
- **Desktop**: Generally maintains connections but may timeout after extended periods
|
||||
- **PWA Standalone**: Better connection persistence but still subject to system limitations
|
||||
|
||||
**Solution**: Always integrate WebSocket services with VisibilityService for automatic recovery.
|
||||
|
||||
#### Memory Leaks
|
||||
|
||||
```typescript
|
||||
// Problem: Not cleaning up registrations
|
||||
// Solution: Proper disposal in onDispose()
|
||||
|
||||
protected async onDispose(): Promise<void> {
|
||||
// ✅ Always clean up registrations
|
||||
if (this.visibilityUnsubscribe) {
|
||||
this.visibilityUnsubscribe()
|
||||
this.visibilityUnsubscribe = undefined
|
||||
}
|
||||
|
||||
// Clean up other resources
|
||||
this.clearTimers()
|
||||
this.closeConnections()
|
||||
}
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging for visibility-related issues:
|
||||
|
||||
```typescript
|
||||
// In your service constructor or initialization
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
// Enable visibility service debugging
|
||||
if (this.visibilityService) {
|
||||
this.visibilityService.on('debug', (message: string, data?: any) => {
|
||||
console.log(`[VisibilityService] ${message}`, data)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check visibility state in browser console:
|
||||
|
||||
```javascript
|
||||
// Get current visibility state
|
||||
console.log(visibilityService.getState())
|
||||
|
||||
// Force connection check
|
||||
await visibilityService.forceConnectionCheck()
|
||||
|
||||
// Check service registrations
|
||||
console.log(visibilityService.subscribedServices.size)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment-Based Configuration
|
||||
|
||||
```typescript
|
||||
// src/config/visibility.ts
|
||||
export const getVisibilityConfig = () => {
|
||||
const isMobile = /Mobile|Android|iPhone|iPad/.test(navigator.userAgent)
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches
|
||||
|
||||
if (isPWA) {
|
||||
return {
|
||||
reconnectThreshold: 15000, // 15s for PWAs
|
||||
debounceDelay: 50,
|
||||
healthCheckInterval: 2000,
|
||||
pauseDelay: 3000
|
||||
}
|
||||
} else if (isMobile) {
|
||||
return {
|
||||
reconnectThreshold: 30000, // 30s for mobile web
|
||||
debounceDelay: 100,
|
||||
healthCheckInterval: 5000,
|
||||
pauseDelay: 5000
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
reconnectThreshold: 60000, // 60s for desktop
|
||||
debounceDelay: 50,
|
||||
healthCheckInterval: 3000,
|
||||
pauseDelay: 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Module-Specific Configuration
|
||||
|
||||
```typescript
|
||||
// Each module can provide custom visibility config
|
||||
export interface ModuleVisibilityConfig {
|
||||
enableVisibilityManagement?: boolean
|
||||
customThresholds?: {
|
||||
reconnectThreshold?: number
|
||||
pauseDelay?: number
|
||||
}
|
||||
criticalService?: boolean // Never pause critical services
|
||||
}
|
||||
|
||||
export const chatModule: ModulePlugin = {
|
||||
name: 'chat',
|
||||
visibilityConfig: {
|
||||
enableVisibilityManagement: true,
|
||||
customThresholds: {
|
||||
reconnectThreshold: 10000, // Chat needs faster reconnection
|
||||
pauseDelay: 2000
|
||||
},
|
||||
criticalService: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The VisibilityService provides a powerful, centralized way to manage app visibility and connection states across all modules. By following the integration patterns and best practices outlined in this documentation, your services will automatically benefit from:
|
||||
|
||||
- **Optimized battery life** on mobile devices
|
||||
- **Reliable connection recovery** after app visibility changes
|
||||
- **Intelligent reconnection logic** based on hidden duration
|
||||
- **Seamless subscription restoration** for real-time features
|
||||
- **Cross-platform compatibility** for web, mobile, and PWA
|
||||
|
||||
The modular architecture ensures that adding visibility management to any service is straightforward while maintaining the flexibility to customize behavior per service needs.
|
||||
|
||||
For questions or issues, check the troubleshooting section or review the real-world examples for implementation guidance.
|
||||
Loading…
Add table
Add a link
Reference in a new issue