Initial commit

This commit is contained in:
Lucien Greathouse
2017-11-29 17:25:37 -08:00
commit 7838b2e67d
30 changed files with 2825 additions and 0 deletions

56
plugin/.luacheckrc Normal file
View File

@@ -0,0 +1,56 @@
stds.roblox = {
read_globals = {
game = {
other_fields = true,
},
-- Roblox globals
"script",
-- Extra functions
"tick", "warn", "spawn",
"wait", "settings",
-- Types
"Vector2", "Vector3",
"Color3",
"UDim", "UDim2",
"Rect",
"CFrame",
"Enum",
"Instance",
}
}
stds.plugin = {
read_globals = {
"plugin",
}
}
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}
}
ignore = {
"212", -- unused arguments
"421", -- shadowing local variable
"422", -- shadowing argument
"431", -- shadowing upvalue
"432", -- shadowing upvalue argument
}
std = "lua51+roblox"
files["**/*.server.lua"] = {
std = "+plugin",
}
files["**/*-spec.lua"] = {
std = "+testez",
}

4
plugin/rbxfs.json Normal file
View File

@@ -0,0 +1,4 @@
{
"rootDirectory": "src",
"rootObject": "ReplicatedStorage.Rojo"
}

3
plugin/src/Config.lua Normal file
View File

@@ -0,0 +1,3 @@
return {
pollingRate = 0.3,
}

54
plugin/src/Http.lua Normal file
View File

@@ -0,0 +1,54 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Promise)
local HttpError = require(script.Parent.HttpError)
local HttpResponse = require(script.Parent.HttpResponse)
local Http = {}
Http.__index = Http
function Http.new(baseUrl)
assert(type(baseUrl) == "string", "Http.new needs a baseUrl!")
local http = {
baseUrl = baseUrl
}
setmetatable(http, Http)
return http
end
function Http:get(endpoint)
return Promise.new(function(resolve, reject)
spawn(function()
local ok, result = pcall(function()
return HttpService:GetAsync(self.baseUrl .. endpoint, true)
end)
if ok then
resolve(HttpResponse.new(result))
else
reject(HttpError.fromErrorString(result))
end
end)
end)
end
function Http:post(endpoint, body)
return Promise.new(function(resolve, reject)
spawn(function()
local ok, result = pcall(function()
return HttpService:PostAsync(self.baseUrl .. endpoint, body)
end)
if ok then
resolve(HttpResponse.new(result))
else
reject(HttpError.fromErrorString(result))
end
end)
end)
end
return Http

57
plugin/src/HttpError.lua Normal file
View File

@@ -0,0 +1,57 @@
local HttpError = {}
HttpError.__index = HttpError
HttpError.Error = {
HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" ..
"Check your game settings, located in the 'Home' tab of Studio.",
},
ConnectFailed = {
message = "Rojo plugin couldn't connect to the Rojo server.\n" ..
"Make sure the server is running -- use 'Rojo serve' to run it!",
},
Unknown = {
message = "Rojo encountered an unknown error: {{message}}",
},
}
function HttpError.new(type, extraMessage)
extraMessage = extraMessage or ""
local message = type.message:gsub("{{message}}", extraMessage)
local err = {
type = type,
message = message,
}
setmetatable(err, HttpError)
return err
end
function HttpError:__tostring()
return self.message
end
--[[
This method shouldn't have to exist. Ugh.
]]
function HttpError.fromErrorString(err)
err = err:lower()
if err:find("^http requests are not enabled") then
return HttpError.new(HttpError.Error.HttpNotEnabled)
end
if err:find("^curl error") then
return HttpError.new(HttpError.Error.ConnectFailed)
end
return HttpError.new(HttpError.Error.Unknown, err)
end
function HttpError:report()
warn(self.message)
end
return HttpError

View File

@@ -0,0 +1,20 @@
local HttpService = game:GetService("HttpService")
local HttpResponse = {}
HttpResponse.__index = HttpResponse
function HttpResponse.new(body)
local response = {
body = body,
}
setmetatable(response, HttpResponse)
return response
end
function HttpResponse:json()
return HttpService:JSONDecode(self.body)
end
return HttpResponse

View File

@@ -0,0 +1,30 @@
if not plugin then
return
end
local Plugin = require(script.Parent.Plugin)
local function main()
local pluginInstance = Plugin.new()
local toolbar = plugin:CreateToolbar("Rojo Plugin 0.1.0")
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "")
.Click:Connect(function()
pluginInstance:connect()
end)
toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "")
.Click:Connect(function()
pluginInstance:syncIn()
end)
toolbar:CreateButton("Toggle Polling", "Poll server for changes", "")
.Click:Connect(function()
spawn(function()
pluginInstance:togglePolling()
end)
end)
end
main()

256
plugin/src/Plugin.lua Normal file
View File

@@ -0,0 +1,256 @@
local Config = require(script.Parent.Config)
local Http = require(script.Parent.Http)
local Server = require(script.Parent.Server)
local Promise = require(script.Parent.Promise)
local function collectMatch(source, pattern)
local result = {}
for match in source:gmatch(pattern) do
table.insert(result, match)
end
return result
end
local function fileToName(filename)
if filename:find("%.server%.lua$") then
return filename:match("^(.-)%.server%.lua$"), "Script"
elseif filename:find("%.client%.lua$") then
return filename:match("^(.-)%.client%.lua$"), "LocalScript"
elseif filename:find("%.lua") then
return filename:match("^(.-)%.lua$"), "ModuleScript"
else
return filename, "StringValue"
end
end
local function nameToInstance(filename, contents)
local name, className = fileToName(filename)
local instance = Instance.new(className)
instance.Name = name
if className:find("Script$") then
instance.Source = contents
else
instance.Value = contents
end
return instance
end
local function make(item, name)
if item.type == "dir" then
local instance = Instance.new("Folder")
instance.Name = name
for childName, child in pairs(item.children) do
make(child, childName).Parent = instance
end
return instance
elseif item.type == "file" then
return nameToInstance(name, item.contents)
else
error("not implemented")
end
end
local function write(parent, route, item)
local location = parent
for index = 1, #route - 1 do
local piece = route[index]
local newLocation = location:FindFirstChild(piece)
if not newLocation then
newLocation = Instance.new("Folder")
newLocation.Name = piece
newLocation.Parent = location
end
location = newLocation
end
local fileName = route[#route]
local name = fileToName(fileName)
local existing = location:FindFirstChild(name)
local new
if item then
new = make(item, fileName)
end
if existing then
existing:Destroy()
end
if new then
new.Parent = location
end
end
local Plugin = {}
Plugin.__index = Plugin
function Plugin.new()
local address = "localhost"
local port = 8081
local remote = ("http://%s:%d"):format(address, port)
local foop = {
_http = Http.new(remote),
_server = nil,
_polling = false,
}
setmetatable(foop, Plugin)
do
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "Rojo UI"
screenGui.Parent = game.CoreGui
screenGui.DisplayOrder = -1
screenGui.Enabled = false
local label = Instance.new("TextLabel")
label.Font = Enum.Font.SourceSans
label.TextSize = 20
label.Text = "Rojo polling..."
label.BackgroundColor3 = Color3.fromRGB(31, 31, 31)
label.BackgroundTransparency = 0.5
label.BorderSizePixel = 0
label.TextColor3 = Color3.new(1, 1, 1)
label.Size = UDim2.new(0, 120, 0, 28)
label.Position = UDim2.new(0, 0, 0, 0)
label.Parent = screenGui
foop._label = screenGui
end
return foop
end
function Plugin:server()
if not self._server then
self._server = Server.connect(self._http)
:catch(function(err)
self._server = nil
return Promise.reject(err)
end)
end
return self._server
end
function Plugin:connect()
print("Testing connection...")
self:server()
:andThen(function(server)
return server:getInfo()
end)
:andThen(function(result)
print("Server found!")
print("Protocol version:", result.protocolVersion)
print("Server version:", result.serverVersion)
end)
end
function Plugin:togglePolling()
if self._polling then
self:stopPolling()
else
self:startPolling()
end
end
function Plugin:stopPolling()
if not self._polling then
return
end
print("Stopping polling...")
self._polling = false
self._label.Enabled = false
end
function Plugin:startPolling()
if self._polling then
return
end
print("Starting to poll...")
self._polling = true
self._label.Enabled = true
return self:server()
:andThen(function(server)
self:syncIn():await()
local project = server:getInfo():await().project
while self._polling do
local changes = server:getChanges():await()
local routes = {}
for _, change in ipairs(changes) do
table.insert(routes, change.route)
end
local items = server:read(routes):await()
for index = 1, #routes do
local partitionName = routes[index][1]
local partition = project.partitions[partitionName]
local data = items[index]
local fullRoute = collectMatch(partition.target, "[^.]+")
write(game, fullRoute, data)
end
wait(Config.pollingRate)
end
end)
:catch(function()
self:stopPolling()
end)
end
function Plugin:syncIn()
print("Syncing from server...")
return self:server()
:andThen(function(server)
local project = server:getInfo():await().project
local readRoutes = {}
for name in pairs(project.partitions) do
table.insert(readRoutes, {name})
end
local items = server:read(readRoutes):await()
for index = 1, #readRoutes do
local partitionName = readRoutes[index][1]
local partition = project.partitions[partitionName]
local data = items[index]
local fullRoute = collectMatch(partition.target, "[^.]+")
write(game, fullRoute, data)
end
print("Sync successful!")
end)
end
return Plugin

View File

@@ -0,0 +1,70 @@
return function()
local Promise = require(script.Parent.Promise)
describe("Promise.new", function()
it("should instantiate with a callback", function()
local promise = Promise.new(function() end)
expect(promise).to.be.ok()
end)
it("should invoke the given callback with resolve and reject", function()
local callCount = 0
local resolveArg
local rejectArg
local promise = Promise.new(function(resolve, reject)
callCount = callCount + 1
resolveArg = resolve
rejectArg = reject
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(resolveArg).to.be.a("function")
expect(rejectArg).to.be.a("function")
expect(promise._status).to.equal(Promise.Status.Started)
end)
it("should resolve promises on resolve()", function()
local callCount = 0
local promise = Promise.new(function(resolve)
callCount = callCount + 1
resolve()
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise._status).to.equal(Promise.Status.Resolved)
end)
it("should reject promises on reject()", function()
local callCount = 0
local promise = Promise.new(function(resolve, reject)
callCount = callCount + 1
reject()
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise._status).to.equal(Promise.Status.Rejected)
end)
it("should reject on error in callback", function()
local callCount = 0
local promise = Promise.new(function()
callCount = callCount + 1
error("hahah")
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise._status).to.equal(Promise.Status.Rejected)
expect(promise._value[1]:find("hahah")).to.be.ok()
end)
end)
end

290
plugin/src/Promise.lua Normal file
View File

@@ -0,0 +1,290 @@
--[[
An implementation of Promises similar to Promise/A+.
]]
local PROMISE_DEBUG = true
-- If promise debugging is on, use a version of pcall that warns on failure.
-- This is useful for finding errors that happen within Promise itself.
local wpcall
if PROMISE_DEBUG then
wpcall = function(f, ...)
local result = { pcall(f, ...) }
if not result[1] then
warn(result[2])
end
return unpack(result)
end
else
wpcall = pcall
end
--[[
Creates a function that invokes a callback with correct error handling and
resolution mechanisms.
]]
local function createAdvancer(callback, resolve, reject)
return function(...)
local result = { wpcall(callback, ...) }
local ok = table.remove(result, 1)
if ok then
resolve(unpack(result))
else
reject(unpack(result))
end
end
end
local function isEmpty(t)
return next(t) == nil
end
local Promise = {}
Promise.__index = Promise
Promise.Status = {
Started = "Started",
Resolved = "Resolved",
Rejected = "Rejected",
}
--[[
Constructs a new Promise with the given initializing callback.
This is generally only called when directly wrapping a non-promise API into
a promise-based version.
The callback will receive 'resolve' and 'reject' methods, used to start
invoking the promise chain.
For example:
local function get(url)
return Promise.new(function(resolve, reject)
spawn(function()
resolve(HttpService:GetAsync(url))
end)
end)
end
get("https://google.com")
:andThen(function(stuff)
print("Got some stuff!", stuff)
end)
]]
function Promise.new(callback)
local promise = {
-- Used to locate where a promise was created
_source = debug.traceback(),
-- A tag to identify us as a promise
_type = "Promise",
_status = Promise.Status.Started,
-- A table containing a list of all results, whether success or failure.
-- Only valid if _status is set to something besides Started
_value = nil,
-- Queues representing functions we should invoke when we update!
_queuedResolve = {},
_queuedReject = {},
}
setmetatable(promise, Promise)
local function resolve(...)
promise:_resolve(...)
end
local function reject(...)
promise:_reject(...)
end
local ok, err = wpcall(callback, resolve, reject)
if not ok and promise._status == Promise.Status.Started then
reject(err)
end
return promise
end
--[[
Create a promise that represents the immediately resolved value.
]]
function Promise.resolve(value)
return Promise.new(function(resolve)
resolve(value)
end)
end
--[[
Create a promise that represents the immediately rejected value.
]]
function Promise.reject(value)
return Promise.new(function(_, reject)
reject(value)
end)
end
--[[
Returns a new promise that:
* is resolved when all input promises resolve
* is rejected if ANY input promises reject
]]
function Promise.all(...)
error("unimplemented", 2)
end
--[[
Is the given object a Promise instance?
]]
function Promise.is(object)
if type(object) ~= "table" then
return false
end
return object._type == "Promise"
end
--[[
Creates a new promise that receives the result of this promise.
The given callbacks are invoked depending on that result.
]]
function Promise:andThen(successHandler, failureHandler)
-- Create a new promise to follow this part of the chain
return Promise.new(function(resolve, reject)
-- Our default callbacks just pass values onto the next promise.
-- This lets success and failure cascade correctly!
local successCallback = resolve
if successHandler then
successCallback = createAdvancer(successHandler, resolve, reject)
end
local failureCallback = reject
if failureHandler then
failureCallback = createAdvancer(failureHandler, resolve, reject)
end
if self._status == Promise.Status.Started then
-- If we haven't resolved yet, put ourselves into the queue
table.insert(self._queuedResolve, successCallback)
table.insert(self._queuedReject, failureCallback)
elseif self._status == Promise.Status.Resolved then
-- This promise has already resolved! Trigger success immediately.
successCallback(unpack(self._value))
elseif self._status == Promise.Status.Rejected then
-- This promise died a terrible death! Trigger failure immediately.
failureCallback(unpack(self._value))
end
end)
end
--[[
Used to catch any errors that may have occurred in the promise.
]]
function Promise:catch(failureCallback)
return self:andThen(nil, failureCallback)
end
--[[
Yield until the promise is completed.
This matches the execution model of normal Roblox functions.
]]
function Promise:await()
if self._status == Promise.Status.Started then
local result
local bindable = Instance.new("BindableEvent")
self:andThen(function(...)
result = {...}
bindable:Fire(true)
end, function(...)
result = {...}
bindable:Fire(false)
end)
local ok = bindable.Event:Wait()
bindable:Destroy()
if not ok then
error(tostring(result[1]), 2)
end
return unpack(result)
elseif self._status == Promise.Status.Resolved then
return unpack(self._value)
elseif self._status == Promise.Status.Rejected then
error(tostring(self._value[1]), 2)
end
end
function Promise:_resolve(...)
if self._status ~= Promise.Status.Started then
return
end
-- If the resolved value was a Promise, we chain onto it!
if Promise.is((...)) then
-- Without this warning, arguments sometimes mysteriously disappear
if select("#", ...) > 1 then
local message = ("When returning a Promise from andThen, extra arguments are discarded! See:\n\n%s"):format(
self._source
)
warn(message)
end
(...):andThen(function(...)
self:_resolve(...)
end, function(...)
self:_reject(...)
end)
return
end
self._status = Promise.Status.Resolved
self._value = {...}
-- We assume that these callbacks will not throw errors.
for _, callback in ipairs(self._queuedResolve) do
callback(...)
end
end
function Promise:_reject(...)
if self._status ~= Promise.Status.Started then
return
end
self._status = Promise.Status.Rejected
self._value = {...}
-- If there are any rejection handlers, call those!
if not isEmpty(self._queuedReject) then
-- We assume that these callbacks will not throw errors.
for _, callback in ipairs(self._queuedReject) do
callback(...)
end
else
-- At this point, no one was able to observe the error.
-- An error handler might still be attached if the error occurred
-- synchronously. We'll wait one tick, and if there are still no
-- observers, then we should put a message in the console.
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
tostring((...)),
self._source
)
warn(message)
end
end
return Promise

69
plugin/src/Server.lua Normal file
View File

@@ -0,0 +1,69 @@
local HttpService = game:GetService("HttpService")
local Server = {}
Server.__index = Server
--[[
Create a new Server using the given HTTP implementation and replacer.
If the context becomes invalid, `replacer` will be invoked with a new
context that should be suitable to replace this one.
Attempting to invoke methods on an invalid conext will throw errors!
]]
function Server.connect(http)
local context = {
http = http,
serverId = nil,
currentTime = 0,
}
setmetatable(context, Server)
return context:_start()
end
function Server:_start()
return self:getInfo()
:andThen(function(response)
self.serverId = response.serverId
self.currentTime = response.currentTime
return self
end)
end
function Server:getInfo()
return self.http:get("/")
:andThen(function(response)
response = response:json()
return response
end)
end
function Server:read(paths)
local body = HttpService:JSONEncode(paths)
return self.http:post("/read", body)
:andThen(function(response)
response = response:json()
return response.items
end)
end
function Server:getChanges()
local url = ("/changes/%f"):format(self.currentTime)
return self.http:get(url)
:andThen(function(response)
response = response:json()
self.currentTime = response.currentTime
return response.changes
end)
end
return Server