diff --git a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift index d1a6b147..75854c26 100644 --- a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift +++ b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift @@ -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) diff --git a/Sources/JavaScriptKit/BasicObjects/JSArray.swift b/Sources/JavaScriptKit/BasicObjects/JSArray.swift index 142145a3..2452c17e 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSArray.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSArray.swift @@ -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! diff --git a/Sources/JavaScriptKit/BasicObjects/JSError.swift b/Sources/JavaScriptKit/BasicObjects/JSError.swift index 8edd3f69..a72027c6 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSError.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSError.swift @@ -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. @@ -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 { diff --git a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift new file mode 100644 index 00000000..a98945cd --- /dev/null +++ b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift @@ -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`. In the rare case, where you can't guarantee that the error thrown +is of actual JavaScript `Error` type, you should use `JSPromise`. + +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: 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) -> ()) -> ()) { + 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) -> ()) -> ()) { + 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( + success: @escaping (Success) -> ResultType, + file: StaticString = #file, + line: Int = #line + ) -> JSPromise { + 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( + success: @escaping (Success) -> JSPromise, + file: StaticString = #file, + line: Int = #line + ) -> JSPromise { + 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`( + failure: @escaping (Failure) -> ResultSuccess, + file: StaticString = #file, + line: Int = #line + ) -> JSPromise { + 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`( + failure: @escaping (Failure) -> JSPromise, + file: StaticString = #file, + line: Int = #line + ) -> JSPromise { + 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!) + } +} diff --git a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift index 371a0900..71c54e65 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift @@ -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: JSBridgedClass, ExpressibleByArrayLiteral where Element: TypedArrayElement { public static var constructor: JSFunction { Element.typedArrayClass }