diff --git a/IntegrationTests/TestSuites/Sources/ConcurrencyTests/UnitTestUtils.swift b/IntegrationTests/TestSuites/Sources/ConcurrencyTests/UnitTestUtils.swift index 1557764a2..40c3165da 100644 --- a/IntegrationTests/TestSuites/Sources/ConcurrencyTests/UnitTestUtils.swift +++ b/IntegrationTests/TestSuites/Sources/ConcurrencyTests/UnitTestUtils.swift @@ -41,6 +41,18 @@ struct MessageError: Error { } } +func expectGTE( + _ lhs: T, _ rhs: T, + file: StaticString = #file, line: UInt = #line, column: UInt = #column +) throws { + if lhs < rhs { + throw MessageError( + "Expected \(lhs) to be greater than or equal to \(rhs)", + file: file, line: line, column: column + ) + } +} + func expectEqual( _ lhs: T, _ rhs: T, file: StaticString = #file, line: UInt = #line, column: UInt = #column diff --git a/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift b/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift index 5447032fe..3bce2aa8d 100644 --- a/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift +++ b/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift @@ -62,7 +62,7 @@ func entrypoint() async throws { let start = time(nil) try await Task.sleep(nanoseconds: 2_000_000_000) let diff = difftime(time(nil), start); - try expectEqual(diff >= 2, true) + try expectGTE(diff, 2) } try await asyncTest("Job reordering based on priority") { @@ -97,6 +97,85 @@ func entrypoint() async throws { _ = await (t3.value, t4.value, t5.value) try expectEqual(context.completed, ["t4", "t3", "t5"]) } + + try await asyncTest("Async JSClosure") { + let delayClosure = JSClosure.async { _ -> JSValue in + try await Task.sleep(nanoseconds: 2_000_000_000) + return JSValue.number(3) + } + let delayObject = JSObject.global.Object.function!.new() + delayObject.closure = delayClosure.jsValue + + let start = time(nil) + let promise = JSPromise(from: delayObject.closure!()) + try expectNotNil(promise) + let result = try await promise!.value + let diff = difftime(time(nil), start) + try expectGTE(diff, 2) + try expectEqual(result, .number(3)) + } + + try await asyncTest("Async JSPromise: then") { + let promise = JSPromise { resolve in + _ = JSObject.global.setTimeout!( + JSClosure { _ in + resolve(.success(JSValue.number(3))) + return .undefined + }.jsValue, + 1_000 + ) + } + let promise2 = promise.then { result in + try await Task.sleep(nanoseconds: 1_000_000_000) + return String(result.number!) + } + let start = time(nil) + let result = try await promise2.value + let diff = difftime(time(nil), start) + try expectGTE(diff, 2) + try expectEqual(result, .string("3.0")) + } + + try await asyncTest("Async JSPromise: then(success:failure:)") { + let promise = JSPromise { resolve in + _ = JSObject.global.setTimeout!( + JSClosure { _ in + resolve(.failure(JSError(message: "test").jsValue)) + return .undefined + }.jsValue, + 1_000 + ) + } + let promise2 = promise.then { _ in + throw JSError(message: "should not succeed") + } failure: { err in + return err + } + let result = try await promise2.value + try expectEqual(result.object?.message, .string("test")) + } + + try await asyncTest("Async JSPromise: catch") { + let promise = JSPromise { resolve in + _ = JSObject.global.setTimeout!( + JSClosure { _ in + resolve(.failure(JSError(message: "test").jsValue)) + return .undefined + }.jsValue, + 1_000 + ) + } + let promise2 = promise.catch { err in + try await Task.sleep(nanoseconds: 1_000_000_000) + return err + } + let start = time(nil) + let result = try await promise2.value + let diff = difftime(time(nil), start) + try expectGTE(diff, 2) + try expectEqual(result.object?.message, .string("test")) + } + // FIXME(katei): Somehow it doesn't work due to a mysterious unreachable inst // at the end of thunk. // This issue is not only on JS host environment, but also on standalone coop executor. diff --git a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift index 81a124447..4b366d812 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift @@ -1,14 +1,4 @@ -/** 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. - */ +/// A wrapper around [the JavaScript `Promise` class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise) public final class JSPromise: JSBridgedClass { /// The underlying JavaScript `Promise` object. public let jsObject: JSObject @@ -27,25 +17,27 @@ public final class JSPromise: JSBridgedClass { 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`. - */ + /// 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 convenience init?(_ jsObject: JSObject) { self.init(from: 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`. - */ + /// 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(jsObject) } - /** 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. - */ + /// Creates a new `JSPromise` instance from a given `resolver` closure. + /// The closure is passed a completion handler. Passing a successful + /// `Result` to the completion handler will cause the promise to resolve + /// with the corresponding value; passing a failure `Result` will cause the + /// promise to reject with the corresponding value. + /// Calling the completion handler more than once will have no effect + /// (per the JavaScript specification). public convenience init(resolver: @escaping (@escaping (Result) -> Void) -> Void) { let closure = JSOneshotClosure { arguments in // The arguments are always coming from the `Promise` constructor, so we should be @@ -74,8 +66,7 @@ public final class JSPromise: JSBridgedClass { self.init(unsafelyWrapping: Self.constructor!.reject!(reason).object!) } - /** Schedules the `success` closure to be invoked on sucessful completion of `self`. - */ + /// Schedules the `success` closure to be invoked on successful completion of `self`. @discardableResult public func then(success: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise { let closure = JSOneshotClosure { @@ -84,8 +75,19 @@ public final class JSPromise: JSBridgedClass { return JSPromise(unsafelyWrapping: jsObject.then!(closure).object!) } - /** Schedules the `success` closure to be invoked on sucessful completion of `self`. - */ + #if compiler(>=5.5) + /// Schedules the `success` closure to be invoked on successful completion of `self`. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + @discardableResult + public func then(success: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { + let closure = JSOneshotClosure.async { + try await success($0[0]).jsValue + } + return JSPromise(unsafelyWrapping: jsObject.then!(closure).object!) + } + #endif + + /// Schedules the `success` closure to be invoked on successful completion of `self`. @discardableResult public func then( success: @escaping (JSValue) -> ConvertibleToJSValue, @@ -100,8 +102,24 @@ public final class JSPromise: JSBridgedClass { return JSPromise(unsafelyWrapping: jsObject.then!(successClosure, failureClosure).object!) } - /** Schedules the `failure` closure to be invoked on rejected completion of `self`. - */ + #if compiler(>=5.5) + /// Schedules the `success` closure to be invoked on successful completion of `self`. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + @discardableResult + public func then(success: @escaping (JSValue) async throws -> ConvertibleToJSValue, + failure: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise + { + let successClosure = JSOneshotClosure.async { + try await success($0[0]).jsValue + } + let failureClosure = JSOneshotClosure.async { + try await failure($0[0]).jsValue + } + return JSPromise(unsafelyWrapping: jsObject.then!(successClosure, failureClosure).object!) + } + #endif + + /// Schedules the `failure` closure to be invoked on rejected completion of `self`. @discardableResult public func `catch`(failure: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise { let closure = JSOneshotClosure { @@ -110,9 +128,20 @@ public final class JSPromise: JSBridgedClass { return .init(unsafelyWrapping: jsObject.catch!(closure).object!) } - /** Schedules the `failure` closure to be invoked on either successful or rejected completion of - `self`. - */ + #if compiler(>=5.5) + /// Schedules the `failure` closure to be invoked on rejected completion of `self`. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + @discardableResult + public func `catch`(failure: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { + let closure = JSOneshotClosure.async { + try await failure($0[0]).jsValue + } + return .init(unsafelyWrapping: jsObject.catch!(closure).object!) + } + #endif + + /// Schedules the `failure` closure to be invoked on either successful or rejected + /// completion of `self`. @discardableResult public func finally(successOrFailure: @escaping () -> Void) -> JSPromise { let closure = JSOneshotClosure { _ in diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift index 48c4f9e82..0bff81403 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift @@ -32,6 +32,13 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { }) } + #if compiler(>=5.5) + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public static func async(_ body: @escaping ([JSValue]) async throws -> JSValue) -> JSOneshotClosure { + JSOneshotClosure(makeAsyncClosure(body)) + } + #endif + /// Release this function resource. /// After calling `release`, calling this function from JavaScript will fail. public func release() { @@ -88,6 +95,13 @@ public class JSClosure: JSObject, JSClosureProtocol { Self.sharedClosures[hostFuncRef] = (self, body) } + #if compiler(>=5.5) + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public static func async(_ body: @escaping ([JSValue]) async throws -> JSValue) -> JSClosure { + JSClosure(makeAsyncClosure(body)) + } + #endif + #if JAVASCRIPTKIT_WITHOUT_WEAKREFS deinit { guard isReleased else { @@ -97,6 +111,27 @@ public class JSClosure: JSObject, JSClosureProtocol { #endif } +#if compiler(>=5.5) +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +private func makeAsyncClosure(_ body: @escaping ([JSValue]) async throws -> JSValue) -> (([JSValue]) -> JSValue) { + { arguments in + JSPromise { resolver in + Task { + do { + let result = try await body(arguments) + resolver(.success(result)) + } catch { + if let jsError = error as? JSError { + resolver(.failure(jsError.jsValue)) + } else { + resolver(.failure(JSError(message: String(describing: error)).jsValue)) + } + } + } + }.jsValue() + } +} +#endif // MARK: - `JSClosure` mechanism note //