luau-promise/lib/init.lua

326 lines
7.4 KiB
Lua
Raw Normal View History

2018-01-26 01:21:58 +00:00
--[[
An implementation of Promises similar to Promise/A+.
]]
local PROMISE_DEBUG = false
local function wpcall(f, ...)
local args = {...}
local argsLength = select("#", ...)
local result = {
xpcall(function()
return f(unpack(args, 1, argsLength))
end, debug.traceback)
}
-- If promise debugging is on, warn whenever a pcall fails.
-- This is useful for debugging issues within the Promise implementation
-- itself.
if PROMISE_DEBUG and not result[1] then
warn(result[2])
2018-01-26 01:21:58 +00:00
end
return unpack(result)
2018-01-26 01:21:58 +00:00
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")
2018-04-11 22:26:54 +00:00
:andThen(function(stuff)
print("Got some stuff!", stuff)
2018-01-26 01:21:58 +00:00
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
_values = nil,
2018-01-26 01:21:58 +00:00
-- Lua doesn't like sparse arrays very much, so we explicitly store the
-- length of _values to handle middle nils.
_valuesLength = -1,
2018-01-26 01:21:58 +00:00
-- If an error occurs with no observers, this will be set.
_unhandledRejection = false,
-- 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
]]
2018-04-11 22:26:54 +00:00
function Promise.all(...)
error("unimplemented", 2)
2018-01-26 01:21:58 +00:00
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
function Promise:getStatus()
return self._status
end
2018-01-26 01:21:58 +00:00
--[[
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)
self._unhandledRejection = false
-- 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
2018-04-11 22:26:54 +00:00
-- If we haven't resolved yet, put ourselves into the queue
2018-01-26 01:21:58 +00:00
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._values))
2018-01-26 01:21:58 +00:00
elseif self._status == Promise.Status.Rejected then
-- This promise died a terrible death! Trigger failure immediately.
failureCallback(unpack(self._values))
2018-01-26 01:21:58 +00:00
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()
self._unhandledRejection = false
if self._status == Promise.Status.Started then
local result
local resultLength
2018-01-26 01:21:58 +00:00
local bindable = Instance.new("BindableEvent")
self:andThen(function(...)
result = {...}
resultLength = select("#", ...)
2018-01-26 01:21:58 +00:00
bindable:Fire(true)
end, function(...)
result = {...}
resultLength = select("#", ...)
2018-01-26 01:21:58 +00:00
bindable:Fire(false)
end)
local ok = bindable.Event:Wait()
bindable:Destroy()
return ok, unpack(result, 1, resultLength)
2018-01-26 01:21:58 +00:00
elseif self._status == Promise.Status.Resolved then
return true, unpack(self._values, 1, self._valuesLength)
2018-01-26 01:21:58 +00:00
elseif self._status == Promise.Status.Rejected then
return false, unpack(self._values, 1, self._valuesLength)
2018-01-26 01:21:58 +00:00
end
end
function Promise:_resolve(...)
if self._status ~= Promise.Status.Started then
return
end
local argLength = select("#", ...)
2018-01-26 01:21:58 +00:00
-- If the resolved value was a Promise, we chain onto it!
if Promise.is((...)) then
-- Without this warning, arguments sometimes mysteriously disappear
if argLength > 1 then
2018-01-26 01:21:58 +00:00
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._values = {...}
self._valuesLength = argLength
2018-01-26 01:21:58 +00:00
-- 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._values = {...}
self._valuesLength = select("#", ...)
2018-01-26 01:21:58 +00:00
-- 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.
self._unhandledRejection = true
local err = tostring((...))
spawn(function()
-- Someone observed the error, hooray!
if not self._unhandledRejection then
return
end
-- Build a reasonable message
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
err,
self._source
)
warn(message)
end)
end
end
2018-04-11 22:26:54 +00:00
return Promise