Skip to content

Add async closure support #159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 5, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ struct MessageError: Error {
}
}

func expectGTE<T: Comparable>(
_ 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<T: Equatable>(
_ lhs: T, _ rhs: T,
file: StaticString = #file, line: UInt = #line, column: UInt = #column
Expand Down
81 changes: 80 additions & 1 deletion IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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.
Expand Down
89 changes: 59 additions & 30 deletions Sources/JavaScriptKit/BasicObjects/JSPromise.swift
Original file line number Diff line number Diff line change
@@ -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<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.
*/
/// 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
Expand All @@ -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<JSValue, JSValue>) -> Void) -> Void) {
let closure = JSOneshotClosure { arguments in
// The arguments are always coming from the `Promise` constructor, so we should be
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
//
Expand Down