diff options
author | 2016-02-21 11:48:45 +0100 | |
---|---|---|
committer | 2016-02-21 11:48:45 +0100 | |
commit | cc24a339d8c0517259084b5c178d784626ba965c (patch) | |
tree | 9868e9687b5802ae0a3733712a3bbeb3bc75c953 /plugins/weblit | |
parent | b5daabda5495dea5c50e17961ecfed2ea8619d76 (diff) |
Merge remote-tracking branch 'refs/remotes/mamedev/master'
Second attempt
Diffstat (limited to 'plugins/weblit')
-rw-r--r-- | plugins/weblit/LICENSE | 22 | ||||
-rw-r--r-- | plugins/weblit/README.md | 239 | ||||
-rw-r--r-- | plugins/weblit/app.lua | 261 | ||||
-rw-r--r-- | plugins/weblit/auto-headers.lua | 92 | ||||
-rw-r--r-- | plugins/weblit/etag-cache.lua | 39 | ||||
-rw-r--r-- | plugins/weblit/init.lua | 8 | ||||
-rw-r--r-- | plugins/weblit/logger.lua | 10 | ||||
-rw-r--r-- | plugins/weblit/plugin.json | 8 | ||||
-rw-r--r-- | plugins/weblit/static.lua | 62 | ||||
-rw-r--r-- | plugins/weblit/websocket.lua | 82 |
10 files changed, 823 insertions, 0 deletions
diff --git a/plugins/weblit/LICENSE b/plugins/weblit/LICENSE new file mode 100644 index 00000000000..5789b767285 --- /dev/null +++ b/plugins/weblit/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Tim Caswell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/plugins/weblit/README.md b/plugins/weblit/README.md new file mode 100644 index 00000000000..55d6085d22b --- /dev/null +++ b/plugins/weblit/README.md @@ -0,0 +1,239 @@ +# weblit + +A web framework for luv (ported from luvit/lit) + +Weblit is a collection of lit packages that together form a nice web framework. + +## weblit/app + +This is the core of the framework. It's export value is the app itself. The +config functions can be chained off this config for super terse syntax. + +```lua +require('weblit/app') + + .bind({ + host = "0.0.0.0", + port = 8080 + }) + + .use(require('weblit/logger')) + .use(require('weblit/auto-headers')) + .use(require('weblit/etag-cache')) + + .route({ + method = "GET", + path = "/do/:user/:action", + domain = "*.myapp.io" + }, function (req, res, go) + -- Handle route + end) + + .start() + +``` + +### bind(options) + +Use this to configure your server. You can bind to multiple addresses and +ports. For example, the same server can listen on port `8080` using normal HTTP +while also listening on port `8443` using HTTPS. + +```lua +-- Listen on port 8080 internally using plain HTTP. +.bind({ + host = "127.0.0.1", + port = 8080 +}) + +-- Also listen on port 8443 externally using encrypted HTTPS. +.bind({ + host = "0.0.0.0", + port = 8443, + tls = { + cert = module:load("cert.pem"), + key = module:load("key.pem", + } +}) +``` + +The `host` option defaults to `"127.0.0.1"`. The default port depends on if +you're running as root and if the connection is TLS encrypted. + + | Root | User +------|-----:|------: +HTTP | 80 | 8080 +HTTPS | 442 | 8443 + + +### use(middleware) + +This adds a raw middleware to the chain. It's signature is: + +```lua +.use(function (req, res, go) + -- Log the request table + p("request", req) + -- Hand off to the next layer. + return go() +end) +``` + +The `req` table will contain information about the HTTP request. This includes +several fields: + + - `socket` - The raw libuv `uv_tty_t` socket. + - `method` - The HTTP request method verb like `GET` or `POST`. + - `path` - The raw HTTP request path (including query string). + - `headers` - A list of headers. Each header is a table with two entries for + key and value. For convenience, there are special `__index` and + `__newindex` metamethods that let you treat this like a case insensitive + key/value store. + - `version` - The HTTP version (Usually either `1.0` or `1.1`). + - `keepAlive` - A flag telling you if this should be a keepalive connection. + - `body` - The request body as a string. In the future, this may also be a stream. + +The `res` table also has some conventions used to form the response a piece at a +time. Initially it contains: + + - `code` - The response status code. Initially contains `404`. + - `headers` - Another special headers table like in `req`. + - `body` - The response body to send. Initially contains `"Not Found\n"`. + +The `go` function is to be called if you wish to not handle a request. This +allows other middleware layers to get a chance to respond to the request. Use a +tail call if there is nothing more to do. + +Otherwise do further processing after `go` returns. At this point, all inner +layers have finished and a response is ready in `res`. + +### route(options, middleware) + +Route is like use, but allows you to pre-filter the requests before the middleware +is called. + +```lua +.route({ + method = "PUT", + path = "/upload/:username" +}, function (req, res, go) + local url = saveFile(req.params.username, req.body) + res.code = 201 + res.headers.Location = url +end) +``` + +The route options accept several parameters: + + - `method` - This is a simple filter on a specific HTTP method verb. + - `path` - This is either an exact match or can contain patterns. Segments + looking like `:name` will match single path segments while `:name:` will + match multiple segments. The matches will go into `req.params`. Also any + query string will be stripped off, parsed out, and stored in `req.query`. + - `host` - Will filter against the `Host` header. This can be an exact match + or a glob match like `*.mydomain.org`. + - `filter` - Filter is a custom lua function that accepts `req` and returns + `true` or `false`. + +If the request matches all the requirements, then the middleware is called the +same as with `use`. + +### start + +Bind to the port(s), listen on the socket(s) and start accepting connections. + +## weblit/logger + +This is a simple middleware that logs the request method, url and user agent. +It also includes the response status code. + +Make sure to use it at the top of your middleware chain so that it's able to see +the final response code sent to the client. + +```lua +.use(require('weblit/logger')) +``` + +## weblit/auto-headers + +This implements lots of conventions and useful defaults that help your app +implement a proper HTTP server. + +You should always use this near the top of the list. The only middleware that +goes before this is the logger. + + +```lua +.use(require('weblit/auto-headers')) +``` + +## weblit/etag-cache + +This caches responses in memory keyed by etag. If there is no etag, but there +is a response body, it will use the body to generate an etag. + +Put this in your list after auto-headers, but before custom server logic. + +```lua +.use(require('weblit/etag-cache')) +``` + +## weblit/static + +This middleware serves static files to the user. Use this to serve your client- +side web assets. + +Usage is pretty simplistic for now. + +```lua +local static = require('weblit/static') +app.use(static("path/to/static/assets")) +``` + +If you want to only match a sub-path, use the router. + +```lua +app.route({ + path = "/blog/:path:" +}, static(pathJoin(module.dir, "articles"))) +``` + +The `path` param will be used if it exists and the full path will be used +otherwise. + +## weblit/websocket + +This implements a websocket upgrade handler. You can choose the subprotocol and +other routing information. + +```lua +app.websocket({ + path = "/v2/socket", -- Prefix for matching + protocol = "virgo/2.0", -- Restrict to a websocket sub-protocol +}, function (req, read, write) + -- Log the request headers + p(req) + -- Log and echo all messages + for message in read do + write(message) + end + -- End the stream + write() +end) +``` + + +## weblit + +This is the metapackage that simply includes the other modules. + +It exposes the other modules as a single exports table. + +```lua +exports.app = require('weblit/app') +exports.autoHeaders = require('weblit/auto-headers') +exports.etagCache = require('weblit/etag-cache') +exports.logger = require('weblit/logger') +exports.static = require('weblit/static') +exports.websocket = require('weblit/websocket') +``` diff --git a/plugins/weblit/app.lua b/plugins/weblit/app.lua new file mode 100644 index 00000000000..d3839c1df8e --- /dev/null +++ b/plugins/weblit/app.lua @@ -0,0 +1,261 @@ + + +local createServer = require('coro-net').createServer +local wrapper = require('coro-wrapper') +local readWrap, writeWrap = wrapper.reader, wrapper.writer +local httpCodec = require('http-codec') +--local tlsWrap = require('coro-tls').wrap +local parseQuery = require('querystring').parse + +-- Ignore SIGPIPE if it exists on platform +local uv = require('luv') +if uv.constants.SIGPIPE then + uv.new_signal():start("sigpipe") +end + +local server = {} +local handlers = {} +local bindings = {} + +-- Provide a nice case insensitive interface to headers. +local headerMeta = { + __index = function (list, name) + if type(name) ~= "string" then + return rawget(list, name) + end + name = name:lower() + for i = 1, #list do + local key, value = unpack(list[i]) + if key:lower() == name then return value end + end + end, + __newindex = function (list, name, value) + -- non-string keys go through as-is. + if type(name) ~= "string" then + return rawset(list, name, value) + end + -- First remove any existing pairs with matching key + local lowerName = name:lower() + for i = #list, 1, -1 do + if list[i][1]:lower() == lowerName then + table.remove(list, i) + end + end + -- If value is nil, we're done + if value == nil then return end + -- Otherwise, set the key(s) + if (type(value) == "table") then + -- We accept a table of strings + for i = 1, #value do + rawset(list, #list + 1, {name, tostring(value[i])}) + end + else + -- Or a single value interperted as string + rawset(list, #list + 1, {name, tostring(value)}) + end + end, +} + +local function handleRequest(head, input, socket) + local req = { + socket = socket, + method = head.method, + path = head.path, + headers = setmetatable({}, headerMeta), + version = head.version, + keepAlive = head.keepAlive, + body = input + } + for i = 1, #head do + req.headers[i] = head[i] + end + + local res = { + code = 404, + headers = setmetatable({}, headerMeta), + body = "Not Found\n", + } + + local function run(i) + local success, err = pcall(function () + i = i or 1 + local go = i < #handlers + and function () + return run(i + 1) + end + or function () end + return handlers[i](req, res, go) + end) + if not success then + res.code = 500 + res.headers = setmetatable({}, headerMeta) + res.body = err + print(err) + end + end + run(1) + + local out = { + code = res.code, + keepAlive = res.keepAlive, + } + for i = 1, #res.headers do + out[i] = res.headers[i] + end + return out, res.body, res.upgrade +end + +local function handleConnection(rawRead, rawWrite, socket) + + -- Speak in HTTP events + local read, updateDecoder = readWrap(rawRead, httpCodec.decoder()) + local write, updateEncoder = writeWrap(rawWrite, httpCodec.encoder()) + + for head in read do + local parts = {} + for chunk in read do + if #chunk > 0 then + parts[#parts + 1] = chunk + else + break + end + end + local res, body, upgrade = handleRequest(head, #parts > 0 and table.concat(parts) or nil, socket) + write(res) + if upgrade then + return upgrade(read, write, updateDecoder, updateEncoder, socket) + end + write(body) + if not (res.keepAlive and head.keepAlive) then + break + end + end + write() + +end + +function server.bind(options) + if not options.host then + options.host = "127.0.0.1" + end + if not options.port then + options.port = require('uv').getuid() == 0 and + (options.tls and 443 or 80) or + (options.tls and 8443 or 8080) + end + bindings[#bindings + 1] = options + return server +end + +function server.use(handler) + handlers[#handlers + 1] = handler + return server +end + + +function server.start() + if #bindings == 0 then + server.bind({}) + end + for i = 1, #bindings do + local options = bindings[i] + createServer(options, function (rawRead, rawWrite, socket) + --local tls = options.tls + --if tls then + --rawRead, rawWrite = tlsWrap(rawRead, rawWrite, { + -- server = true, + --key = assert(tls.key, "tls key required"), + --cert = assert(tls.cert, "tls cert required"), + --}) + --end + return handleConnection(rawRead, rawWrite, socket) + end) + print("HTTP server listening at http" .. (options.tls and "s" or "") .. "://" .. options.host .. (options.port == (options.tls and 443 or 80) and "" or ":" .. options.port) .. "/") + end + return server +end + +local quotepattern = '(['..("%^$().[]*+-?"):gsub("(.)", "%%%1")..'])' +local function escape(str) + return str:gsub(quotepattern, "%%%1") +end + +local function compileGlob(glob) + local parts = {"^"} + for a, b in glob:gmatch("([^*]*)(%**)") do + if #a > 0 then + parts[#parts + 1] = escape(a) + end + if #b > 0 then + parts[#parts + 1] = "(.*)" + end + end + parts[#parts + 1] = "$" + local pattern = table.concat(parts) + return function (string) + return string and string:match(pattern) + end +end + +local function compileRoute(route) + local parts = {"^"} + local names = {} + for a, b, c, d in route:gmatch("([^:]*):([_%a][_%w]*)(:?)([^:]*)") do + if #a > 0 then + parts[#parts + 1] = escape(a) + end + if #c > 0 then + parts[#parts + 1] = "(.*)" + else + parts[#parts + 1] = "([^/]*)" + end + names[#names + 1] = b + if #d > 0 then + parts[#parts + 1] = escape(d) + end + end + if #parts == 1 then + return function (string) + if string == route then return {} end + end + end + parts[#parts + 1] = "$" + local pattern = table.concat(parts) + return function (string) + local matches = {string:match(pattern)} + if #matches > 0 then + local results = {} + for i = 1, #matches do + results[i] = matches[i] + results[names[i]] = matches[i] + end + return results + end + end +end + +function server.route(options, handler) + local method = options.method + local path = options.path and compileRoute(options.path) + local host = options.host and compileGlob(options.host) + local filter = options.filter + server.use(function (req, res, go) + if method and req.method ~= method then return go() end + if host and not host(req.headers.host) then return go() end + if filter and not filter(req) then return go() end + local params + if path then + local pathname, query = req.path:match("^([^?]*)%??(.*)") + params = path(pathname) + if not params then return go() end + if #query > 0 then + req.query = parseQuery(query) + end + end + req.params = params or {} + return handler(req, res, go) + end) + return server +end + +return server diff --git a/plugins/weblit/auto-headers.lua b/plugins/weblit/auto-headers.lua new file mode 100644 index 00000000000..44ad65779e9 --- /dev/null +++ b/plugins/weblit/auto-headers.lua @@ -0,0 +1,92 @@ + + +--[[ + +Response automatic values: + - Auto Server header + - Auto Date Header + - code defaults to 404 with body "Not Found\n" + - if there is a string body add Content-Length and ETag if missing + - if string body and no Content-Type, use text/plain for valid utf-8, application/octet-stream otherwise + - Auto add "; charset=utf-8" to Content-Type when body is known to be valid utf-8 + - Auto 304 responses for if-none-match requests + - Auto strip body with HEAD requests + - Auto chunked encoding if body with unknown length + - if Connection header set and not keep-alive, set res.keepAlive to false + - Add Connection Keep-Alive/Close if not found based on res.keepAlive + +--TODO: utf8 scanning + +]] + +--local digest = require('openssl').digest.digest +local date = require('os').date + +return function (req, res, go) + local isHead = false + if req.method == "HEAD" then + req.method = "GET" + isHead = true + end + + local requested = req.headers["if-none-match"] + + go() + + -- We could use the fancy metatable, but this is much faster + local lowerHeaders = {} + local headers = res.headers + for i = 1, #headers do + local key, value = unpack(headers[i]) + lowerHeaders[key:lower()] = value + end + + + if not lowerHeaders.server then + headers[#headers + 1] = {"Server", serverName} + end + if not lowerHeaders.date then + headers[#headers + 1] = {"Date", date("!%a, %d %b %Y %H:%M:%S GMT")} + end + + if not lowerHeaders.connection then + if req.keepAlive then + lowerHeaders.connection = "Keep-Alive" + headers[#headers + 1] = {"Connection", "Keep-Alive"} + else + headers[#headers + 1] = {"Connection", "Close"} + end + end + res.keepAlive = lowerHeaders.connection and lowerHeaders.connection:lower() == "keep-alive" + + local body = res.body + if body then + local needLength = not lowerHeaders["content-length"] and not lowerHeaders["transfer-encoding"] + if type(body) == "string" then + if needLength then + headers[#headers + 1] = {"Content-Length", #body} + end + -- if not lowerHeaders.etag then + -- local etag = '"' .. digest("sha1", body) .. '"' + -- lowerHeaders.etag = etag + --headers[#headers + 1] = {"ETag", etag} + -- end + else + if needLength then + headers[#headers + 1] = {"Transfer-Encoding", "chunked"} + end + end + if not lowerHeaders["content-type"] then + headers[#headers + 1] = {"Content-Type", "text/plain"} + end + end + + local etag = lowerHeaders.etag + if requested and res.code >= 200 and res.code < 300 and requested == etag then + res.code = 304 + body = nil + end + + if isHead then body = nil end + res.body = body +end diff --git a/plugins/weblit/etag-cache.lua b/plugins/weblit/etag-cache.lua new file mode 100644 index 00000000000..e8c5d149b35 --- /dev/null +++ b/plugins/weblit/etag-cache.lua @@ -0,0 +1,39 @@ + +local function clone(headers) + local copy = setmetatable({}, getmetatable(headers)) + for i = 1, #headers do + copy[i] = headers[i] + end + return copy +end + +local cache = {} +return function (req, res, go) + local requested = req.headers["If-None-Match"] + local host = req.headers.Host + local key = host and host .. "|" .. req.path or req.path + local cached = cache[key] + if not requested and cached then + req.headers["If-None-Match"] = cached.etag + end + go() + local etag = res.headers.ETag + if not etag then return end + if res.code >= 200 and res.code < 300 then + local body = res.body + if not body or type(body) == "string" then + cache[key] = { + etag = etag, + code = res.code, + headers = clone(res.headers), + body = body + } + end + elseif res.code == 304 then + if not requested and cached and etag == cached.etag then + res.code = cached.code + res.headers = clone(cached.headers) + res.body = cached.body + end + end +end diff --git a/plugins/weblit/init.lua b/plugins/weblit/init.lua new file mode 100644 index 00000000000..f9224b7880c --- /dev/null +++ b/plugins/weblit/init.lua @@ -0,0 +1,8 @@ +local exports = {} +exports.app = require('weblit/app') +exports.autoHeaders = require('weblit/auto-headers') +exports.etagCache = require('weblit/etag-cache') +exports.logger = require('weblit/logger') +exports.static = require('weblit/static') +exports.websocket = require('weblit/websocket') +return exports diff --git a/plugins/weblit/logger.lua b/plugins/weblit/logger.lua new file mode 100644 index 00000000000..912b4eed768 --- /dev/null +++ b/plugins/weblit/logger.lua @@ -0,0 +1,10 @@ + +return function (req, res, go) + -- Skip this layer for clients who don't send User-Agent headers. + local userAgent = req.headers["user-agent"] + if not userAgent then return go() end + -- Run all inner layers first. + go() + -- And then log after everything is done + --print(string.format("%s %s %s %s", req.method, req.path, userAgent, res.code)) +end diff --git a/plugins/weblit/plugin.json b/plugins/weblit/plugin.json new file mode 100644 index 00000000000..69dd45ccc0e --- /dev/null +++ b/plugins/weblit/plugin.json @@ -0,0 +1,8 @@ +{ + "plugin": { + "name": "weblit", + "version": "1.0.0", + "author": "Tim Caswell", + "type": "library", + } +}
\ No newline at end of file diff --git a/plugins/weblit/static.lua b/plugins/weblit/static.lua new file mode 100644 index 00000000000..b34ea638fa1 --- /dev/null +++ b/plugins/weblit/static.lua @@ -0,0 +1,62 @@ + +local getType = require("mime").getType +local jsonStringify = require('json').stringify + +local makeChroot = require('coro-fs').chroot + +return function (rootPath) + + local fs = makeChroot(rootPath) + + return function (req, res, go) + if req.method ~= "GET" then return go() end + local path = (req.params and req.params.path) or req.path + path = path:match("^[^?#]*") + if path:byte(1) == 47 then + path = path:sub(2) + end + local stat = fs.stat(path) + if not stat then return go() end + + local function renderFile() + local body = assert(fs.readFile(path)) + res.code = 200 + res.headers["Content-Type"] = getType(path) + res.body = body + return + end + + local function renderDirectory() + if req.path:byte(-1) ~= 47 then + res.code = 301 + res.headers.Location = req.path .. '/' + return + end + local files = {} + for entry in fs.scandir(path) do + if entry.name == "index.html" and entry.type == "file" then + path = (#path > 0 and path .. "/" or "") .. "index.html" + return renderFile() + end + files[#files + 1] = entry + entry.url = "http://" .. req.headers.host .. req.path .. entry.name + end + local body = jsonStringify(files) .. "\n" + res.code = 200 + res.headers["Content-Type"] = "application/json" + res.body = body + return + end + + if stat.type == "directory" then + return renderDirectory() + elseif stat.type == "file" then + if req.path:byte(-1) == 47 then + res.code = 301 + res.headers.Location = req.path:match("^(.*[^/])/+$") + return + end + return renderFile() + end + end +end diff --git a/plugins/weblit/websocket.lua b/plugins/weblit/websocket.lua new file mode 100644 index 00000000000..d5dfe572ea6 --- /dev/null +++ b/plugins/weblit/websocket.lua @@ -0,0 +1,82 @@ + +local websocketCodec = require('websocket-codec') + +local function websocketHandler(options, handler) + return function (req, res, go) + -- Websocket connections must be GET requests + -- with 'Upgrade: websocket' + -- and 'Connection: Upgrade' headers + local headers = req.headers + local connection = headers.connection + local upgrade = headers.upgrade + if not ( + req.method == "GET" and + upgrade and upgrade:lower():find("websocket", 1, true) and + connection and connection:lower():find("upgrade", 1, true) + ) then + return go() + end + + if options.filter and not options.filter(req) then + return go() + end + + -- If there is a sub-protocol specified, filter on it. + local protocol = options.protocol + if protocol then + local list = headers["sec-websocket-protocol"] + local foundProtocol + if list then + for item in list:gmatch("[^, ]+") do + if item == protocol then + foundProtocol = true + break + end + end + end + if not foundProtocol then + return go() + end + end + + -- Make sure it's a new client speaking v13 of the protocol + assert(tonumber(headers["sec-websocket-version"]) >= 13, "only websocket protocol v13 supported") + + -- Get the security key + local key = assert(headers["sec-websocket-key"], "websocket security required") + + res.code = 101 + headers = res.headers + headers.Upgrade = "websocket" + headers.Connection = "Upgrade" + headers["Sec-WebSocket-Accept"] = websocketCodec.acceptKey(key) + if protocol then + headers["Sec-WebSocket-Protocol"] = protocol + end + function res.upgrade(read, write, updateDecoder, updateEncoder) + updateDecoder(websocketCodec.decode) + updateEncoder(websocketCodec.encode) + local success, err = pcall(handler, req, read, write) + if not success then + print(err) + write({ + opcode = 1, + payload = err, + }) + return write() + end + end + end +end + +local server = require('weblit-app') +function server.websocket(options, handler) + server.route({ + method = "GET", + path = options.path, + host = options.host, + }, websocketHandler(options, handler)) + return server +end + +return websocketHandler |