/** * Minimal Pusher-protocol WebSocket server for Laravel broadcasting. * Compatible with laravel-echo + pusher-js (no cloud Pusher account needed). */ const http = require('http'); const crypto = require('crypto'); const WebSocket = require('ws'); const fs = require('fs'); const path = require('path'); const { URL } = require('url'); const configPath = path.join(__dirname, 'soketi.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); const app = (config['appManager.array.apps'] || [])[0] || {}; const APP_ID = app.id || 'ultimatepos'; const APP_KEY = app.key || 'ultimatepos-key'; const APP_SECRET = app.secret || 'ultimatepos-secret'; const HOST = config.host || '0.0.0.0'; const PORT = config.port || 6001; /** @type {Map>} */ const channelSubscribers = new Map(); /** @type {Map }>} */ const socketMeta = new Map(); function socketId() { // Pusher PHP SDK requires format: digits.digits return `${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`; } function parseBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk) => { body += chunk; if (body.length > 1_000_000) { reject(new Error('Body too large')); req.destroy(); } }); req.on('end', () => resolve(body)); req.on('error', reject); }); } function verifyTriggerSignature(req, body, requestPath, query) { const remote = req.socket.remoteAddress || ''; const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1'; const authKey = query.get('auth_key'); const authSignature = query.get('auth_signature'); if (!authSignature) { return isLoopback; } if (authKey && authKey !== APP_KEY) { return false; } const params = {}; [...query.entries()].forEach(([key, value]) => { params[key] = value; }); delete params.auth_signature; const sorted = Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join('&'); const stringToSign = `${req.method}\n${requestPath}\n${sorted}`; const expected = crypto.createHmac('sha256', APP_SECRET).update(stringToSign).digest('hex'); try { return crypto.timingSafeEqual(Buffer.from(authSignature), Buffer.from(expected)); } catch { return false; } } function subscribe(ws, channel) { if (!channelSubscribers.has(channel)) { channelSubscribers.set(channel, new Set()); } channelSubscribers.get(channel).add(ws); socketMeta.get(ws).channels.add(channel); } function unsubscribe(ws, channel) { channelSubscribers.get(channel)?.delete(ws); socketMeta.get(ws)?.channels.delete(channel); } function cleanupSocket(ws) { const meta = socketMeta.get(ws); if (!meta) { return; } meta.channels.forEach((channel) => unsubscribe(ws, channel)); socketMeta.delete(ws); } function broadcastToChannel(channel, event, data, excludeWs = null) { const subs = channelSubscribers.get(channel); if (!subs || subs.size === 0) { return 0; } const payload = JSON.stringify({ event, channel, data: typeof data === 'string' ? data : JSON.stringify(data), }); let sent = 0; subs.forEach((ws) => { if (ws === excludeWs) { return; } if (ws.readyState === WebSocket.OPEN) { ws.send(payload); sent += 1; } }); return sent; } const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Pusher-Key, X-Pusher-Signature'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const parsedUrl = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`); const triggerMatch = parsedUrl.pathname.match(/^\/apps\/([^/]+)\/events\/?$/); if (req.method === 'POST' && triggerMatch) { if (triggerMatch[1] !== APP_ID) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unknown app id' })); return; } try { const body = await parseBody(req); if (!verifyTriggerSignature(req, body, parsedUrl.pathname, parsedUrl.searchParams)) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid signature' })); return; } const payload = JSON.parse(body); const eventName = payload.name; const channels = payload.channels || (payload.channel ? [payload.channel] : []); const data = payload.data; let total = 0; channels.forEach((channel) => { total += broadcastToChannel(channel, eventName, data); }); if (config.debug) { console.log(`[broadcast] ${eventName} -> ${channels.join(', ')} (${total} clients)`); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}'); } catch (error) { console.error('[http] trigger error', error); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: error.message })); } return; } res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); }); const wss = new WebSocket.Server({ noServer: true }); server.on('upgrade', (req, socket, head) => { const match = req.url.match(/^\/app\/([^/?]+)/); if (!match || match[1] !== APP_KEY) { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); }); wss.on('connection', (ws) => { const id = socketId(); socketMeta.set(ws, { socketId: id, channels: new Set() }); ws.send(JSON.stringify({ event: 'pusher:connection_established', data: JSON.stringify({ socket_id: id, activity_timeout: 30, }), })); ws.on('message', (raw) => { try { const message = JSON.parse(raw.toString()); if (message.event === 'pusher:subscribe') { const data = typeof message.data === 'string' ? JSON.parse(message.data) : message.data; const channel = data.channel; if (!channel) { return; } if (channel.startsWith('private-') && !data.auth) { ws.send(JSON.stringify({ event: 'pusher:subscription_error', data: JSON.stringify({ type: 'AuthError', error: 'Auth required', status: 401 }), })); return; } subscribe(ws, channel); ws.send(JSON.stringify({ event: 'pusher_internal:subscription_succeeded', channel, data: '{}', })); ws.send(JSON.stringify({ event: 'pusher:subscription_succeeded', channel, data: '{}', })); if (config.debug) { console.log(`[ws] ${id} subscribed to ${channel}`); } } else if (message.event === 'pusher:unsubscribe') { const data = typeof message.data === 'string' ? JSON.parse(message.data) : message.data; if (data.channel) { unsubscribe(ws, data.channel); } } else if (message.event === 'pusher:ping') { ws.send(JSON.stringify({ event: 'pusher:pong', data: {} })); } else if (message.event && message.event.startsWith('client-')) { const channel = message.channel; if (!channel) { return; } const meta = socketMeta.get(ws); if (!meta || !meta.channels.has(channel)) { return; } if (!channel.startsWith('private-') && !channel.startsWith('presence-')) { return; } const sent = broadcastToChannel(channel, message.event, message.data, ws); if (config.debug) { console.log(`[client-event] ${message.event} -> ${channel} (${sent} clients)`); } } } catch (error) { console.error('[ws] message error', error); } }); ws.on('close', () => cleanupSocket(ws)); ws.on('error', () => cleanupSocket(ws)); }); server.listen(PORT, HOST, () => { console.log(`UltimatePOS WebSocket server running at ws://${HOST === '0.0.0.0' ? '127.0.0.1' : HOST}:${PORT}/app/${APP_KEY}`); console.log(`App id: ${APP_ID}`); });