Skip to content
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