diff --git a/LICENSE b/LICENSE index 00d2e13..1625c17 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,121 @@ -This is free and unencumbered software released into the public domain. +Creative Commons Legal Code -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +CC0 1.0 Universal -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. -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 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. +Statement of Purpose -For more information, please refer to \ No newline at end of file +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. \ No newline at end of file diff --git a/README.md b/README.md index 7687a11..d692438 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,78 @@ -# Lua Promise +# Roblox Lua Promise +An implementation of `Promise` similar to Promise/A+. + +## Motivation +I've found that being able to yield anywhere causes lots of bugs. In [Rodux](https://github.com/Roblox/Rodux), I explicitly made it impossible to yield in a change handler because of the sheer number of bugs that occured when callbacks randomly yielded. + +As such, I think that Roblox needs an object-based async primitive. It's not important to me whether these are Promises, Observables, Task objects, or Futures. + +The important traits are: + +* An object that represents a unit of asynchronous work +* Composability +* Predictable timing + +This Promise implementation attempts to satisfy those traits. + +## API + +### Static Functions +* `Promise.new((resolve, reject) -> nil) -> Promise` + * Construct a new Promise that will be resolved or rejected with the given callbacks. +* `Promise.resolve(value) -> Promise` + * Creates an immediately resolved Promise with the given value. +* `Promise.reject(value) -> Promise` + * Creates an immediately rejected Promise with the given value. +* `Promise.is(object) -> bool` + * Returns whether the given object is a Promise. + +### Instance Methods +* `Promise:andThen(successHandler, [failureHandler]) -> Promise` + * Chains onto an existing Promise and returns a new Promise. + * Equivalent to the Promise/A+ `then` method. +* `Promise:catch(failureHandler) -> Promise` + * Shorthand for `Promise:andThen(nil, failureHandler)`. +* `Promise:await() -> ok, value` + * Yields the current thread until the given Promise completes. Returns `ok` as a bool, followed by the value that the promise returned. + +## Example +This Promise implementation finished synchronously. In order to wrap an existing async API, you should use `spawn` or `delay` in order to prevent your calling thread from accidentally yielding. + +```lua +local HttpService = game:GetService("HttpService") + +-- A light wrapper around HttpService +-- Ideally, you do this once per project per async method that you use. +local function httpGet(url) + return Promise.new(function(resolve, reject) + -- Spawn to prevent yielding, since GetAsync yields. + spawn(function() + local ok, result = pcall(HttpService.GetAsync, HttpService, url) + + if ok then + resolve(result) + else + reject(result) + end + end) + end) +end + +-- Usage +httpGet("https://google.com") + :andThen(function(body) + print("Here's the Google homepage:", body) + end) + :catch(function(err) + warn("We failed to get the Google homepage!", err) + end) +``` + +## Future Additions +* `Promise.all` + * Currently stubbed out, throws an error. +* `Promise.wrapAsync` + * Intended to wrap an existing Roblox API that yields, exposing a new one that returns a Promise. ## License -Lua Promise is available under the terms of the Unlicense. See [LICENSE](LICENSE) for details. \ No newline at end of file +This project is available under the CC0 license. See [LICENSE](LICENSE) for details. \ No newline at end of file diff --git a/lib/Promise.spec.lua b/lib/Promise.spec.lua deleted file mode 100644 index 1e104cc..0000000 --- a/lib/Promise.spec.lua +++ /dev/null @@ -1,129 +0,0 @@ -return function() - local Promise = require(script.Parent.Promise) - - describe("new", function() - it("should pass resolve and reject to the callback", function() - local promiseResolve - local promiseReject - local callCount = 0 - - local promise = Promise.new(function(resolve, reject) - callCount = callCount + 1 - promiseResolve = resolve - promiseReject = reject - end) - - expect(promise).to.be.ok() - expect(promiseResolve).to.be.a("function") - expect(promiseReject).to.be.a("function") - expect(callCount).to.equal(1) - end) - - it("should resolve synchronously", function() - local promiseResolve - local callCount = 0 - - local promise = Promise.new(function(resolve) - callCount = callCount + 1 - promiseResolve = resolve - end) - - expect(promise._status).to.equal(Promise.Status.Started) - - promiseResolve(6, 7) - - expect(promise._status).to.equal(Promise.Status.Resolved) - expect(promise._value).to.be.a("table") - expect(#promise._value).to.equal(2) - expect(promise._value[1]).to.equal(6) - expect(promise._value[2]).to.equal(7) - end) - - it("should reject synchronously", function() - local promiseReject - local callCount = 0 - - local promise = Promise.new(function(_, reject) - callCount = callCount + 1 - promiseReject = reject - end) - - expect(promise._status).to.equal(Promise.Status.Started) - - promiseReject(6, 7) - - expect(promise._status).to.equal(Promise.Status.Rejected) - expect(promise._value).to.be.a("table") - expect(#promise._value).to.equal(2) - expect(promise._value[1]).to.equal(6) - expect(promise._value[2]).to.equal(7) - end) - end) - - describe("resolve", function() - it("should be a synchronously resolved promise", function() - local promise = Promise.resolve(3) - - expect(promise._status).to.equal(Promise.Status.Resolved) - expect(promise._value).to.be.a("table") - expect(#promise._value).to.equal(1) - expect(promise._value[1]).to.equal(3) - end) - end) - - describe("reject", function() - it("should be a synchronously rejected promise", function() - local promise = Promise.reject(3) - - expect(promise._status).to.equal(Promise.Status.Rejected) - expect(promise._value).to.be.a("table") - expect(#promise._value).to.equal(1) - expect(promise._value[1]).to.equal(3) - end) - end) - - describe("andThen", function() - it("should be chained with unresolved promises", function() - local rootResolve - local rootCallCount = 0 - local childCallCount = 0 - local childValues - - local root = Promise.new(function(resolve) - rootCallCount = rootCallCount + 1 - rootResolve = resolve - end) - - local child = root:andThen(function(...) - childCallCount = childCallCount + 1 - childValues = {...} - - return "foo" - end) - - expect(root).never.to.equal(child) - expect(rootCallCount).to.equal(1) - expect(childCallCount).to.equal(0) - - expect(root._status).to.equal(Promise.Status.Started) - expect(child._status).to.equal(Promise.Status.Started) - - rootResolve(16, 13) - - expect(root._status).to.equal(Promise.Status.Resolved) - expect(root._value).to.be.a("table") - expect(#root._value).to.equal(2) - expect(root._value[1]).to.equal(16) - expect(root._value[2]).to.equal(13) - - expect(#childValues).to.equal(2) - expect(childValues[1]).to.equal(16) - expect(childValues[2]).to.equal(13) - - expect(child._status).to.equal(Promise.Status.Resolved) - expect(child._value).to.be.a("table") - expect(#child._value).to.equal(1) - expect(child._value[1]).to.equal("foo") - end) - end) -end \ No newline at end of file diff --git a/lib/Promise.lua b/lib/init.lua similarity index 91% rename from lib/Promise.lua rename to lib/init.lua index 8e6b8fd..cdcea79 100644 --- a/lib/Promise.lua +++ b/lib/init.lua @@ -4,8 +4,8 @@ local PROMISE_DEBUG = false --- 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. +-- 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, ...) @@ -71,8 +71,8 @@ Promise.Status = { end get("https://google.com") - :andThen(function(body) - print("Got a body:", body) + :andThen(function(stuff) + print("Got some stuff!", stuff) end) ]] function Promise.new(callback) @@ -139,23 +139,8 @@ end * is resolved when all input promises resolve * is rejected if ANY input promises reject ]] -function Promise.all(promises) - return Promise.new(function(resolve, reject) - local results = {} - local totalCount = #promises - local finishedCount = 0 - - for index, promise in ipairs(promises) do - promise:andThen(function(value) - results[index] = value - finishedCount = finishedCount + 1 - - if finishedCount == totalCount then - resolve(results) - end - end, reject) - end - end) +function Promise.all(...) + error("unimplemented", 2) end --[[ @@ -193,7 +178,7 @@ function Promise:andThen(successHandler, failureHandler) end if self._status == Promise.Status.Started then - -- If we haven't resolved yet, put ourself into the queue + -- 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 @@ -323,4 +308,4 @@ function Promise:_reject(...) end end -return Promise \ No newline at end of file +return Promise diff --git a/lib/init.spec.lua b/lib/init.spec.lua new file mode 100644 index 0000000..3ce1811 --- /dev/null +++ b/lib/init.spec.lua @@ -0,0 +1,262 @@ +return function() + local Promise = require(script.Parent) + + 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) + + describe("Promise.resolve", function() + it("should immediately resolve with a value", function() + local promise = Promise.resolve(5) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Resolved) + expect(promise._value[1]).to.equal(5) + end) + + it("should chain onto passed promises", function() + local promise = Promise.resolve(Promise.new(function(_, reject) + reject(7) + end)) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(7) + end) + end) + + describe("Promise.reject", function() + it("should immediately reject with a value", function() + local promise = Promise.reject(6) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(6) + end) + + it("should pass a promise as-is as an error", function() + local innerPromise = Promise.new(function(resolve) + resolve(6) + end) + + local promise = Promise.reject(innerPromise) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(innerPromise) + end) + end) + + describe("Promise:andThen", function() + it("should chain onto resolved promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local promise = Promise.resolve(5) + + local chained = promise + :andThen(function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end, function() + badCallCount = badCallCount + 1 + end) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(5) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Resolved) + expect(promise._value[1]).to.equal(5) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + + it("should chain onto rejected promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local promise = Promise.reject(5) + + local chained = promise + :andThen(function(...) + badCallCount = badCallCount + 1 + end, function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(5) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(5) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + + it("should chain onto asynchronously resolved promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local startResolution + local promise = Promise.new(function(resolve) + startResolution = resolve + end) + + local chained = promise + :andThen(function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end, function() + badCallCount = badCallCount + 1 + end) + + expect(callCount).to.equal(0) + expect(badCallCount).to.equal(0) + + startResolution(6) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(6) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Resolved) + expect(promise._value[1]).to.equal(6) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + + it("should chain onto asynchronously rejected promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local startResolution + local promise = Promise.new(function(_, reject) + startResolution = reject + end) + + local chained = promise + :andThen(function() + badCallCount = badCallCount + 1 + end, function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end) + + expect(callCount).to.equal(0) + expect(badCallCount).to.equal(0) + + startResolution(6) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(6) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(6) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + end) +end diff --git a/rojo.json b/rojo.json index 77d5f14..7d18107 100644 --- a/rojo.json +++ b/rojo.json @@ -4,7 +4,7 @@ "partitions": { "lib": { "path": "lib", - "target": "ReplicatedStorage.lua-promise" + "target": "ReplicatedStorage.Promise" } } } \ No newline at end of file