--[[ websocket client pure lua implement for love2d by flaribbit usage: local client = require("websocket").new("127.0.0.1", 5000) function client:onmessage(s) print(s) end function client:onopen() self:send("hello from love2d") end function client:onclose() print("closed") end function love.update() client:update() end ]] local socket = require"socket" local seckey = "osT3F7mvlojIvf3/8uIsJQ==" local OPCODE = { CONTINUE = 0, TEXT = 1, BINARY = 2, CLOSE = 8, PING = 9, PONG = 10, } local STATUS = { CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3, TCPOPENING = 4, } ---@class wsclient ---@field socket table ---@field url table ---@field _head integer|nil local _M = { OPCODE = OPCODE, STATUS = STATUS, } _M.__index = _M function _M:onopen() end function _M:onmessage(message) end function _M:onerror(error) end function _M:onclose(code, reason) end ---create websocket connection ---@param host string ---@param port integer ---@param path string ---@return wsclient function _M.new(host, port, path) local m = { url = { host = host, port = port, path = path or "/", }, _continue = "", _buffer = "", _length = 0, _head = nil, status = STATUS.TCPOPENING, socket = socket.tcp(), } m.socket:settimeout(0) m.socket:connect(host, port) setmetatable(m, _M) return m end local mask_key = {1, 14, 5, 14} local function send(sock, opcode, message) -- message type sock:send(string.char((0x80 | opcode))) -- empty message if not message then sock:send(string.char(0x80, table.unpack(mask_key))) return 0 end -- message length local length = #message if length>65535 then sock:send(string.char(127 & 0x80), 0, 0, 0, 0, ((length >> 24) & 0xff), ((length >> 16) & 0xff), ((length >> 8) & 0xff), (length & 0xff)) elseif length>125 then sock:send(string.char((126 | 0x80), ((length >> 8) & 0xff), (length & 0xff))) else sock:send(string.char((length | 0x80))) end -- message sock:send(string.char(table.unpack(mask_key))) local msgbyte = {message:byte(1, length)} for i = 1, length do msgbyte[i] = (msgbyte[i] ~ mask_key[(i-1)%4+1]) end return sock:send(string.char(table.unpack(msgbyte))) end ---read a message ---@return string|nil res message ---@return number|nil head websocket frame header ---@return string|nil err error message function _M:read() local res, err, part ::RECIEVE:: res, err, part = self.socket:receive(self._length-#self._buffer) if err=="closed" then return nil, nil, err end if part or res then self._buffer = self._buffer..(part or res) else return nil, nil, nil end if not self._head then if #self._buffer<2 then return nil, nil, "buffer length less than 2" end local length = (self._buffer:byte(2) & 0x7f) if length==126 then if self._length==2 then self._length = 4 goto RECIEVE end if #self._buffer<4 then return nil, nil, "buffer length less than 4" end local b1, b2 = self._buffer:byte(3, 4) self._length = (b1 << 8) + b2 elseif length==127 then if self._length==2 then self._length = 10 goto RECIEVE end if #self._buffer<10 then return nil, nil, "buffer length less than 10" end local b5, b6, b7, b8 = self._buffer:byte(7, 10) self._length = (b5 << 24) + (b6 << 16) + (b7 << 8) + b8 else self._length = length end self._head, self._buffer = self._buffer:byte(1), "" if length>0 then goto RECIEVE end end if #self._buffer>=self._length then local ret, head = self._buffer, self._head self._length, self._buffer, self._head = 2, "", nil return ret, head, nil else return nil, nil, "buffer length less than "..self._length end end ---send a message ---@param message string function _M:send(message) send(self.socket, OPCODE.TEXT, message) end ---send a ping message ---@param message string function _M:ping(message) send(self.socket, OPCODE.PING, message) end ---send a pong message (no need) ---@param message any function _M:pong(message) send(self.socket, OPCODE.PONG, message) end ---update client status function _M:update() local sock = self.socket if self.status==STATUS.TCPOPENING then local url = self.url local _, err = sock:connect(url.host, url.port) self._length = self._length+1 if err=="already connected" then sock:send( "GET "..url.path.." HTTP/1.1\r\n".. "Host: "..url.host..":"..url.port.."\r\n".. "Connection: Upgrade\r\n".. "Upgrade: websocket\r\n".. "Sec-WebSocket-Version: 13\r\n".. "Sec-WebSocket-Key: "..seckey.."\r\n\r\n") self.status = STATUS.CONNECTING self._length = 2 elseif self._length>600 then self:onerror("connection failed") self.status = STATUS.CLOSED end elseif self.status==STATUS.CONNECTING then local res = sock:receive("*l") if res then repeat res = sock:receive("*l") until res=="" self:onopen() self.status = STATUS.OPEN end elseif self.status==STATUS.OPEN or self.status==STATUS.CLOSING then while true do local res, head, err = self:read() if err=="closed" then self.status = STATUS.CLOSED return elseif res==nil then return end local opcode = (head & 0x0f) local fin = (head & 0x80)==0x80 if opcode==OPCODE.CLOSE then if res~="" then local code = (res:byte(1) << 8) + res:byte(2) self:onclose(code, res:sub(3)) else self:onclose(1005, "") end sock:close() self.status = STATUS.CLOSED elseif opcode==OPCODE.PING then self:pong(res) elseif opcode==OPCODE.CONTINUE then self._continue = self._continue..res if fin then self:onmessage(self._continue) end else if fin then self:onmessage(res) else self._continue = res end end end end end ---close websocket connection ---@param code integer|nil ---@param message string|nil function _M:close(code, message) if code and message then send(self.socket, OPCODE.CLOSE, string.char((code >> 8), (code & 0xff))..message) else send(self.socket, OPCODE.CLOSE, nil) end self.status = STATUS.CLOSING end return _M