local extension = ... -- -config.extensions.web-base.assets ../../assets/www_static/ local class = require('jls.lang.class') local logger = extension:getLogger() local event = require('jls.lang.event') local File = require('jls.io.File') local HttpExchange = require('jls.net.http.HttpExchange') local HttpHandler = require('jls.net.http.HttpHandler') local FileHttpHandler = require('jls.net.http.handler.FileHttpHandler') local ZipFileHttpHandler = require('jls.net.http.handler.ZipFileHttpHandler') local HttpContext = require('jls.net.http.HttpContext') local WebSocketUpgradeHandler = require('jls.net.http.WebSocket').UpgradeHandler local json = require("jls.util.json") local Map = require("jls.util.Map") local List = require("jls.util.List") local tables = require("jls.util.tables") local utils = require('lha.utils') local Logger = logger:getClass() local recordLog = Logger.getLogRecorder() local AddonFileHttpHandler = class.create(FileHttpHandler, function(fileHttpHandler) function fileHttpHandler:getPath(httpExchange) return select(2, httpExchange:getRequestArguments()) end end) local RollingList = class.create(List, function(rollingList) function rollingList:initialize(size) self.index = size self.size = size end function rollingList:add(value) if value == nil then error('Cannot add nil value') end local i = self.index % self.size + 1 local dropped = self[i] self[i] = value self.index = i if dropped then self:onDropped(dropped) end return self end function rollingList:onDropped(value) end function rollingList:values() if #self ~= self.size or self.index == self.size then return self end local l = {} table.move(self, self.index + 1, self.size, 1, l) table.move(self, 1, self.index, 1, l) return l end end) local websockets = {} local addons = {} local function cleanup() websockets = {} addons = {} Logger.setLogRecorder(recordLog) end local function onWebSocketClose(webSocket) logger:fine('WebSocket closed %s', webSocket) List.removeFirst(websockets, webSocket) end local function webSocketBroadcast(data) logger:fine('WebSocket broadcast') local message = json.encode(data) for _, websocket in ipairs(websockets) do websocket:sendTextMessage(message) end end local function webSocketSend(sessionId, data) logger:fine('WebSocket send') local message = json.encode(data) for _, websocket in ipairs(websockets) do if not sessionId or sessionId == websocket.sessionId then websocket:sendTextMessage(message) end end end local batchDataChange = true local dataChangeEvent = nil local function onDataChange(value, previousValue, path) logger:fine('onDataChange() "%s": "%s" %s', path, value, #websockets) if #websockets == 0 then return end if batchDataChange then if not dataChangeEvent then event:setTimeout(function() webSocketBroadcast(dataChangeEvent) dataChangeEvent = nil end) dataChangeEvent = {event = 'data-change'} end tables.setPath(dataChangeEvent, path, value) else local thingId, propertyName = string.match(path, '^data/([^/]+)/([^/]+)$') if thingId then webSocketBroadcast({ event = 'data-change', data = { [thingId] = { [propertyName] = { value = value, previousValue = previousValue } } } }) end end end function extension:registerAddon(id, addon) addons[id] = addon logger:info('Web base add-on "%s" %s', id, addon and 'registered' or 'unregistered') webSocketBroadcast({event = 'addon-change'}) end function extension:registerAddonExtension(ext, script) self:registerAddon(ext:getId(), { handler = AddonFileHttpHandler:new(ext:getDir()), script = script or 'init.js' }) end function extension:unregisterAddonExtension(ext) self:registerAddon(ext:getId()) end function extension:getWebBaseInfo() return { addons = Map.size(addons), websockets = #websockets } end extension:watchPattern('^data/.*', onDataChange) extension:subscribeEvent('startup', function() local configuration = extension:getConfiguration() cleanup() local maxMessageSize = 1024 local maxMessageCount = 60 local maxWebSocketLevel = Logger.LEVEL.FINE local warnMessages = RollingList:new(maxMessageCount // 3) local logMessages = RollingList:new(maxMessageCount - maxMessageCount // 3) function logMessages:onDropped(value) if value.level >= logger.WARN then warnMessages:add(value) end end local bufferMessages Logger.setLogRecorder(function(lgr, time, level, message) recordLog(lgr, time, level, message) if level <= maxWebSocketLevel then -- the websocket stack will emit logs return end if #message > maxMessageSize then message = string.sub(message, 1, maxMessageSize - 3)..'...' end local value = { time = time, level = level, message = message } logMessages:add(value) if #websockets == 0 then bufferMessages = nil else if not bufferMessages then event:setTimeout(function() webSocketBroadcast(bufferMessages) bufferMessages = nil end) bufferMessages = {event = 'logs', logs = {}} end table.insert(bufferMessages.logs, value) end end) local assets = utils.getAbsoluteFile(configuration.assets or 'assets', extension:getDir()) local assetsHandler if assets:isFile() then if string.find(assets:getPathName(), '%.zip$') then logger:info('Using assets file "%s"', assets) assetsHandler = ZipFileHttpHandler:new(assets) else logger:warn('Invalid assets file "%s"', assets) assetsHandler = HttpContext.notFoundHandler end else if assets:isDirectory() then logger:info('Using assets directory "%s"', assets) else logger:warn('Missing assets directory "%s"', assets) end assetsHandler = FileHttpHandler:new(assets):setCacheControl(configuration.cache) end local wwwDir = File:new(extension:getDir(), 'www') extension:addContext('/(.*)', FileHttpHandler:new(wwwDir, 'r', 'app.html')) extension:addContext('/static/(.*)', assetsHandler) extension:addContext('/addon/([^/]*)/?(.*)', HttpHandler:new(function(self, exchange) local name, path = exchange:getRequestArguments() logger:fine('add-on handler "%s" / "%s"', name, path) if name == '' then local list = {} for id, addon in pairs(addons) do table.insert(list, { id = id, script = addon.script }) end HttpExchange.ok(exchange, json.encode(list), 'application/json') else local addon = addons[name] if addon and addon.handler then logger:fine('calling add-on "%s" handler', name) return addon.handler:handle(exchange) end HttpExchange.notFound(exchange) end end)) extension:addContext('/ws/', Map.assign(WebSocketUpgradeHandler:new(), { onOpen = function(_, webSocket, exchange) logger:fine('WebSocket openned %s', webSocket) table.insert(websockets, webSocket) webSocket.onClose = onWebSocketClose local session = exchange:getSession() if session then webSocket.sessionId = session:getId() end end })) extension:addContext('/logs/', HttpHandler:new(function(self, exchange) local l = List.concat({}, logMessages:values(), warnMessages:values()) HttpExchange.ok(exchange, json.stringify(l), 'application/json') end)) logger:info('WebSocket available on /ws/') extension:getEngine():publishEvent('web-base:startup', extension) end) extension:subscribeEvent('shutdown', function() webSocketBroadcast({event = 'shutdown'}) cleanup() end) extension:subscribeEvent('notification', function(message, sessionId) logger:fine('notification: %s', message) if type(message) == 'string' then webSocketSend(sessionId, { event = 'notification', message = message }) end end)