mirror of
https://github.com/AmberGraceRblx/luau-promise.git
synced 2025-04-24 23:50:03 +00:00
This fixes some cases where middle-nils would drop values. Not all cases are fixed because of the definition of wpcall, which could be rectified by replacing it.
325 lines
7.4 KiB
Lua
325 lines
7.4 KiB
Lua
--[[
|
|
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])
|
|
end
|
|
|
|
return unpack(result)
|
|
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
|
|
_values = nil,
|
|
|
|
-- Lua doesn't like sparse arrays very much, so we explicitly store the
|
|
-- length of _values to handle middle nils.
|
|
_valuesLength = -1,
|
|
|
|
-- 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
|
|
]]
|
|
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
|
|
|
|
function Promise:getStatus()
|
|
return self._status
|
|
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)
|
|
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
|
|
-- 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._values))
|
|
elseif self._status == Promise.Status.Rejected then
|
|
-- This promise died a terrible death! Trigger failure immediately.
|
|
failureCallback(unpack(self._values))
|
|
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
|
|
local bindable = Instance.new("BindableEvent")
|
|
|
|
self:andThen(function(...)
|
|
result = {...}
|
|
resultLength = select("#", ...)
|
|
bindable:Fire(true)
|
|
end, function(...)
|
|
result = {...}
|
|
resultLength = select("#", ...)
|
|
bindable:Fire(false)
|
|
end)
|
|
|
|
local ok = bindable.Event:Wait()
|
|
bindable:Destroy()
|
|
|
|
return ok, unpack(result, 1, resultLength)
|
|
elseif self._status == Promise.Status.Resolved then
|
|
return true, unpack(self._values, 1, self._valuesLength)
|
|
elseif self._status == Promise.Status.Rejected then
|
|
return false, unpack(self._values, 1, self._valuesLength)
|
|
end
|
|
end
|
|
|
|
function Promise:_resolve(...)
|
|
if self._status ~= Promise.Status.Started then
|
|
return
|
|
end
|
|
|
|
local argLength = select("#", ...)
|
|
|
|
-- 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
|
|
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
|
|
|
|
-- 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("#", ...)
|
|
|
|
-- 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
|
|
|
|
return Promise
|