Skip to content

Commit

Permalink
Add a generic JSPromise implementation (#62)
Browse files Browse the repository at this point in the history
This provides three overloads each for `then` and `catch`, and I'm not sure if that's good for the type-checker performance and usability. I was thinking of naming the promise-returning callback version `flatMap`, but ultimately decided against it.

`then` overload with two callbacks is not available, because it's impossible to unify success and failure types from both callbacks in a single returned promise without type erasure. I think users should just chain `then` and `catch` in those cases so that type erasure is avoided.
  • Loading branch information
MaxDesiatov committed Sep 24, 2020
1 parent 87c8f73 commit c52ea09
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 4 deletions.
19 changes: 19 additions & 0 deletions IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,25 @@ try test("Timer") {
}
}

var timer: JSTimer?
var promise: JSPromise<(), Never>?

try test("Promise") {
let start = JSDate().valueOf()
let timeoutMilliseconds = 5.0

promise = JSPromise { resolve in
timer = JSTimer(millisecondsDelay: timeoutMilliseconds) {
resolve()
}
}

promise!.then {
// verify that at least `timeoutMilliseconds` passed since the timer started
try! expectEqual(start + timeoutMilliseconds <= JSDate().valueOf(), true)
}
}

try test("Error") {
let message = "test error"
let error = JSError(message: message)
Expand Down
2 changes: 1 addition & 1 deletion Sources/JavaScriptKit/BasicObjects/JSArray.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// A wrapper around [the JavaScript Array class](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array)
/// A wrapper around [the JavaScript Array class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)
/// that exposes its properties in a type-safe and Swifty way.
public class JSArray: JSBridgedClass {
public static let constructor = JSObject.global.Array.function!
Expand Down
9 changes: 7 additions & 2 deletions Sources/JavaScriptKit/BasicObjects/JSError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) that
exposes its properties in a type-safe way.
*/
public final class JSError: Error, JSBridgedClass {
/// The constructor function used to create new `Error` objects.
public final class JSError: Error, JSValueConvertible {
/// The constructor function used to create new JavaScript `Error` objects.
public static let constructor = JSObject.global.Error.function!

/// The underlying JavaScript `Error` object.
Expand Down Expand Up @@ -32,6 +32,11 @@ public final class JSError: Error, JSBridgedClass {
public var stack: String? {
jsObject.stack.string
}

/// Creates a new `JSValue` from this `JSError` instance.
public func jsValue() -> JSValue {
.object(jsObject)
}
}

extension JSError: CustomStringConvertible {
Expand Down
253 changes: 253 additions & 0 deletions Sources/JavaScriptKit/BasicObjects/JSPromise.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
/** A wrapper around [the JavaScript `Promise` class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)
that exposes its functions in a type-safe and Swifty way. The `JSPromise` API is generic over both
`Success` and `Failure` types, which improves compatibility with other statically-typed APIs such
as Combine. If you don't know the exact type of your `Success` value, you should use `JSValue`, e.g.
`JSPromise<JSValue, JSError>`. In the rare case, where you can't guarantee that the error thrown
is of actual JavaScript `Error` type, you should use `JSPromise<JSValue, JSValue>`.
This doesn't 100% match the JavaScript API, as `then` overload with two callbacks is not available.
It's impossible to unify success and failure types from both callbacks in a single returned promise
without type erasure. You should chain `then` and `catch` in those cases to avoid type erasure.
**IMPORTANT**: instances of this class must have the same lifetime as the actual `Promise` object in
the JavaScript environment, because callback handlers will be deallocated when `JSPromise.deinit` is
executed.
If the actual `Promise` object in JavaScript environment lives longer than this `JSPromise`, it may
attempt to call a deallocated `JSClosure`.
*/
public final class JSPromise<Success, Failure>: JSValueConvertible, JSValueConstructible {
/// The underlying JavaScript `Promise` object.
public let jsObject: JSObject

private var callbacks = [JSClosure]()

/// The underlying JavaScript `Promise` object wrapped as `JSValue`.
public func jsValue() -> JSValue {
.object(jsObject)
}

/// This private initializer assumes that the passed object is a JavaScript `Promise`
private init(unsafe object: JSObject) {
self.jsObject = object
}

/** Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `jsObject`
is not an instance of JavaScript `Promise`, this initializer will return `nil`.
*/
public init?(_ jsObject: JSObject) {
guard jsObject.isInstanceOf(JSObject.global.Promise.function!) else { return nil }
self.jsObject = jsObject
}

/** Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `value`
is not an object and is not an instance of JavaScript `Promise`, this function will
return `nil`.
*/
public static func construct(from value: JSValue) -> Self? {
guard case let .object(jsObject) = value else { return nil }
return Self.init(jsObject)
}

/** Schedules the `success` closure to be invoked on sucessful completion of `self`.
*/
public func then(success: @escaping () -> ()) {
let closure = JSClosure { _ in success() }
callbacks.append(closure)
_ = jsObject.then!(closure)
}

/** Schedules the `failure` closure to be invoked on either successful or rejected completion of
`self`.
*/
public func finally(successOrFailure: @escaping () -> ()) -> Self {
let closure = JSClosure { _ in
successOrFailure()
}
callbacks.append(closure)
return .init(unsafe: jsObject.finally!(closure).object!)
}

deinit {
callbacks.forEach { $0.release() }
}
}

extension JSPromise where Success == (), Failure == Never {
/** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes
a closure that your code should call to resolve this `JSPromise` instance.
*/
public convenience init(resolver: @escaping (@escaping () -> ()) -> ()) {
let closure = JSClosure { arguments -> () in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
resolver { arguments[0].function!() }
}
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
callbacks.append(closure)
}
}

extension JSPromise where Failure: JSValueConvertible {
/** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes
two closure that your code should call to either resolve or reject this `JSPromise` instance.
*/
public convenience init(resolver: @escaping (@escaping (Result<Success, JSError>) -> ()) -> ()) {
let closure = JSClosure { arguments -> () in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
let resolve = arguments[0].function!
let reject = arguments[1].function!

resolver {
switch $0 {
case .success:
resolve()
case let .failure(error):
reject(error.jsValue())
}
}
}
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
callbacks.append(closure)
}
}

extension JSPromise where Success: JSValueConvertible, Failure: JSError {
/** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes
a closure that your code should call to either resolve or reject this `JSPromise` instance.
*/
public convenience init(resolver: @escaping (@escaping (Result<Success, JSError>) -> ()) -> ()) {
let closure = JSClosure { arguments -> () in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
let resolve = arguments[0].function!
let reject = arguments[1].function!

resolver {
switch $0 {
case let .success(success):
resolve(success.jsValue())
case let .failure(error):
reject(error.jsValue())
}
}
}
self.init(unsafe: JSObject.global.Promise.function!.new(closure))
callbacks.append(closure)
}
}

extension JSPromise where Success: JSValueConstructible {
/** Schedules the `success` closure to be invoked on sucessful completion of `self`.
*/
public func then(
success: @escaping (Success) -> (),
file: StaticString = #file,
line: Int = #line
) {
let closure = JSClosure { arguments -> () in
guard let result = Success.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap success value for `then` callback")
}
success(result)
}
callbacks.append(closure)
_ = jsObject.then!(closure)
}

/** Returns a new promise created from chaining the current `self` promise with the `success`
closure invoked on sucessful completion of `self`. The returned promise will have a new
`Success` type equal to the return type of `success`.
*/
public func then<ResultType: JSValueConvertible>(
success: @escaping (Success) -> ResultType,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultType, Failure> {
let closure = JSClosure { arguments -> JSValue in
guard let result = Success.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap success value for `then` callback")
}
return success(result).jsValue()
}
callbacks.append(closure)
return .init(unsafe: jsObject.then!(closure).object!)
}

/** Returns a new promise created from chaining the current `self` promise with the `success`
closure invoked on sucessful completion of `self`. The returned promise will have a new type
equal to the return type of `success`.
*/
public func then<ResultSuccess: JSValueConvertible, ResultFailure: JSValueConstructible>(
success: @escaping (Success) -> JSPromise<ResultSuccess, ResultFailure>,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultSuccess, ResultFailure> {
let closure = JSClosure { arguments -> JSValue in
guard let result = Success.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap success value for `then` callback")
}
return success(result).jsValue()
}
callbacks.append(closure)
return .init(unsafe: jsObject.then!(closure).object!)
}
}

extension JSPromise where Failure: JSValueConstructible {
/** Returns a new promise created from chaining the current `self` promise with the `failure`
closure invoked on rejected completion of `self`. The returned promise will have a new `Success`
type equal to the return type of the callback, while the `Failure` type becomes `Never`.
*/
public func `catch`<ResultSuccess: JSValueConvertible>(
failure: @escaping (Failure) -> ResultSuccess,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultSuccess, Never> {
let closure = JSClosure { arguments -> JSValue in
guard let error = Failure.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap error value for `catch` callback")
}
return failure(error).jsValue()
}
callbacks.append(closure)
return .init(unsafe: jsObject.then!(JSValue.undefined, closure).object!)
}

/** Schedules the `failure` closure to be invoked on rejected completion of `self`.
*/
public func `catch`(
failure: @escaping (Failure) -> (),
file: StaticString = #file,
line: Int = #line
) {
let closure = JSClosure { arguments -> () in
guard let error = Failure.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap error value for `catch` callback")
}
failure(error)
}
callbacks.append(closure)
_ = jsObject.then!(JSValue.undefined, closure)
}

/** Returns a new promise created from chaining the current `self` promise with the `failure`
closure invoked on rejected completion of `self`. The returned promise will have a new type
equal to the return type of `success`.
*/
public func `catch`<ResultSuccess: JSValueConvertible, ResultFailure: JSValueConstructible>(
failure: @escaping (Failure) -> JSPromise<ResultSuccess, ResultFailure>,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultSuccess, ResultFailure> {
let closure = JSClosure { arguments -> JSValue in
guard let error = Failure.construct(from: arguments[0]) else {
fatalError("\(file):\(line): failed to unwrap error value for `catch` callback")
}
return failure(error).jsValue()
}
callbacks.append(closure)
return .init(unsafe: jsObject.then!(JSValue.undefined, closure).object!)
}
}
2 changes: 1 addition & 1 deletion Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public protocol TypedArrayElement: JSValueConvertible, JSValueConstructible {
static var typedArrayClass: JSFunction { get }
}

/// A wrapper around all JavaScript [TypedArray](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) classes that exposes their properties in a type-safe way.
/// A wrapper around all JavaScript [TypedArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) classes that exposes their properties in a type-safe way.
/// FIXME: the BigInt-based TypedArrays are not supported (https://github.com/swiftwasm/JavaScriptKit/issues/56)
public class JSTypedArray<Element>: JSBridgedClass, ExpressibleByArrayLiteral where Element: TypedArrayElement {
public static var constructor: JSFunction { Element.typedArrayClass }
Expand Down

0 comments on commit c52ea09

Please sign in to comment.