ultimatepos/websocket-server/server.js

283 lines
9.1 KiB
JavaScript
Vendored

/**
* 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<string, Set<import('ws').WebSocket>>} */
const channelSubscribers = new Map();
/** @type {Map<import('ws').WebSocket, { socketId: string, channels: Set<string> }>} */
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}`);
});