UNPKG

ws402

Version:

WebSocket implementation of X402 protocol for pay-as-you-go digital resources with automatic refunds

257 lines (256 loc) 9.24 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WS402 = void 0; // src/WS402.ts const ws_1 = __importDefault(require("ws")); const events_1 = require("events"); /** * WS402 - WebSocket implementation of X402 protocol * Enables pay-as-you-go pricing for WebSocket resources with automatic refunds */ class WS402 extends events_1.EventEmitter { constructor(config, paymentProvider) { super(); this.sessions = new Map(); this.paymentProvider = paymentProvider; // Set defaults this.config = { updateInterval: config.updateInterval || 3000, pricePerSecond: config.pricePerSecond || 1, currency: config.currency || 'wei', maxSessionDuration: config.maxSessionDuration || 3600, userIdExtractor: config.userIdExtractor || this.defaultUserIdExtractor, onPaymentVerified: config.onPaymentVerified || (() => { }), onRefundIssued: config.onRefundIssued || (() => { }), onSessionEnd: config.onSessionEnd || (() => { }), }; } /** * Attach WS402 to a WebSocket server */ attach(wss) { const userIdToWs = new Map(); wss.on('connection', async (ws, req) => { try { const userId = this.config.userIdExtractor(req); userIdToWs.set(userId, ws); ws.on('close', () => { userIdToWs.delete(userId); }); await this.handleConnection(ws, req); } catch (error) { this.emit('error', error); ws.close(1011, 'Internal server error'); } }); return userIdToWs; } /** * Generate WS402 schema for initial HTTP response * @param pricePerSecond - Optional custom price per second, uses config default if not provided */ generateSchema(resourceId, estimatedDuration, pricePerSecond) { const price = pricePerSecond ?? this.config.pricePerSecond; const totalPrice = price * estimatedDuration; return { protocol: 'ws402', version: '0.1.2', resourceId, websocketEndpoint: `wss://your-server.com/ws402/${resourceId}`, pricing: { pricePerSecond: price, currency: this.config.currency, estimatedDuration, totalPrice, }, paymentDetails: this.paymentProvider.generatePaymentDetails(totalPrice), maxSessionDuration: this.config.maxSessionDuration, }; } /** * Handle new WebSocket connection */ async handleConnection(ws, req) { const userId = this.config.userIdExtractor(req); // Wait for payment proof const paymentProof = await this.waitForPaymentProof(ws); // Verify payment with provider const verification = await this.paymentProvider.verifyPayment(paymentProof); if (!verification.valid) { ws.send(JSON.stringify({ type: 'payment_rejected', reason: verification.reason || 'Invalid payment', })); ws.close(1008, 'Payment verification failed'); return; } // Create session with custom price if provided const session = { userId, sessionId: this.generateSessionId(), startTime: Date.now(), paidAmount: verification.amount, consumedAmount: 0, elapsedSeconds: 0, bytesTransferred: 0, messageCount: 0, status: 'active', paymentProof, pricePerSecond: this.config.pricePerSecond, _resourceId: req._resourceId, // Pass resourceId from request to session }; this.sessions.set(ws, session); this.config.onPaymentVerified(session); // Send confirmation ws.send(JSON.stringify({ type: 'session_started', sessionId: session.sessionId, balance: session.paidAmount, pricePerSecond: session.pricePerSecond, })); // Start usage tracking const interval = setInterval(() => { this.updateUsage(ws, session); }, this.config.updateInterval); // Handle messages (count bytes) ws.on('message', (data) => { const byteLength = Buffer.byteLength(data.toString(), 'utf8'); session.bytesTransferred += byteLength; session.messageCount++; }); // Handle disconnection ws.on('close', () => { clearInterval(interval); this.endSession(ws, session); }); ws.on('error', (error) => { clearInterval(interval); this.emit('error', error); this.endSession(ws, session); }); } /** * Wait for client to send payment proof */ waitForPaymentProof(ws) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Payment proof timeout')); }, 30000); // 30 second timeout const handler = (data) => { try { const message = JSON.parse(data.toString()); if (message.type === 'payment_proof') { clearTimeout(timeout); ws.removeListener('message', handler); resolve(message.proof); } } catch (e) { // Ignore invalid JSON } }; ws.on('message', handler); }); } /** * Update session usage and check limits */ updateUsage(ws, session) { if (ws.readyState !== ws_1.default.OPEN) return; const now = Date.now(); session.elapsedSeconds = Math.floor((now - session.startTime) / 1000); // Use session-specific price instead of global config session.consumedAmount = session.elapsedSeconds * session.pricePerSecond; const remaining = session.paidAmount - session.consumedAmount; // Send update to client const update = { type: 'usage_update', sessionId: session.sessionId, elapsedSeconds: session.elapsedSeconds, consumedAmount: session.consumedAmount, remainingBalance: remaining, bytesTransferred: session.bytesTransferred, messageCount: session.messageCount, }; ws.send(JSON.stringify(update)); // Check if balance exhausted if (remaining <= 0) { ws.send(JSON.stringify({ type: 'balance_exhausted', message: 'Prepaid balance has been fully consumed', })); ws.close(1000, 'Balance exhausted'); } // Check max duration if (session.elapsedSeconds >= this.config.maxSessionDuration) { ws.send(JSON.stringify({ type: 'max_duration_reached', message: 'Maximum session duration reached', })); ws.close(1000, 'Max duration reached'); } } /** * End session and issue refund */ async endSession(ws, session) { session.status = 'ended'; const refundAmount = session.paidAmount - session.consumedAmount; if (refundAmount > 0) { try { const refund = { sessionId: session.sessionId, amount: refundAmount, reason: 'unused_balance', timestamp: Date.now(), }; await this.paymentProvider.issueRefund(session.paymentProof, refundAmount); this.config.onRefundIssued(session, refund); this.emit('refund', { session, refund }); } catch (error) { this.emit('refund_error', { session, error }); } } this.config.onSessionEnd(session); this.emit('session_end', session); this.sessions.delete(ws); } /** * Get active session by user ID */ getSessionByUserId(userId) { for (const session of this.sessions.values()) { if (session.userId === userId) { return session; } } return null; } /** * Get all active sessions */ getActiveSessions() { return Array.from(this.sessions.values()); } /** * Default user ID extractor from request */ defaultUserIdExtractor(req) { const url = new URL(req.url, `http://${req.headers.host}`); return url.searchParams.get('userId') || 'anonymous'; } /** * Generate unique session ID */ generateSessionId() { return `ws402_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } } exports.WS402 = WS402;