Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion Sources/SkipScript/JSContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,50 @@ public class JSContext {
JavaScriptCore.JSGarbageCollect(context)
}

/// Creates a JavaScript Promise and returns the promise along with its resolve and reject functions.
///
/// This allows Swift code to control when a Promise resolves or rejects by calling the
/// returned resolve and reject functions directly.
public func createPromise() -> JSPromise? {
guard let result = evaluateScript("""
(function() {
var resolve, reject;
var promise = new Promise(function(res, rej) { resolve = res; reject = rej; });
return {promise: promise, resolve: resolve, reject: reject};
})()
""") else {
return nil
}
let promise = result.objectForKeyedSubscript("promise")
let resolve = result.objectForKeyedSubscript("resolve")
let reject = result.objectForKeyedSubscript("reject")
return (promise: promise, resolveFunction: resolve, rejectFunction: reject)
}

/// Awaits the resolution of a JavaScript Promise, returning its resolved value.
///
/// If the Promise rejects, a ``JSError`` is thrown with the rejection reason.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func awaitPromise(_ promise: JSValue) async throws -> JSValue {
try await withCheckedThrowingContinuation { continuation in
let onResolve = JSValue(newFunctionIn: self) { ctx, obj, args in
continuation.resume(returning: args.first ?? JSValue(undefinedIn: ctx))
return JSValue(undefinedIn: ctx)
}
let onReject = JSValue(newFunctionIn: self) { ctx, obj, args in
let message = args.first?.toString() ?? "Promise rejected"
continuation.resume(throwing: JSError(message: message))
return JSValue(undefinedIn: ctx)
}
let thenFn = promise.objectForKeyedSubscript("then")
do {
try thenFn.call(withArguments: [onResolve, onReject], this: promise)
} catch {
continuation.resume(throwing: error)
}
}
}

}


Expand Down Expand Up @@ -278,6 +322,35 @@ public class JSValue {
JavaScriptCore.JSValueProtect(context.context, self.value)
}

/// Creates a JavaScript value of the function type that wraps an async Swift callback.
///
/// When called from JavaScript, the function returns a `Promise` that resolves with the
/// async callback's return value, or rejects if the callback throws.
///
/// - Parameters:
/// - context: The execution context to use.
/// - callback: The async callback function.
#if !SKIP
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public convenience init(newAsyncFunctionIn context: JSContext, callback: @escaping (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) async throws -> JSValue) {
self.init(newFunctionIn: context, callback: { ctx, obj, args in
guard let promiseParts = ctx.createPromise() else {
return JSValue(undefinedIn: ctx)
}
Task {
do {
let result = try await callback(ctx, obj, args)
_ = try? promiseParts.resolveFunction.call(withArguments: [result])
} catch {
let errorValue = JSValue(newErrorFromCause: error, in: ctx)
_ = try? promiseParts.rejectFunction.call(withArguments: [errorValue])
}
}
return promiseParts.promise
})
}
#endif

#if !SKIP

/// Creates a JavaScript `Error` object, as if by invoking the built-in `Error` constructor.
Expand Down Expand Up @@ -534,7 +607,7 @@ public class JSValue {

let ctx = self.context
return try context.trying { (exception: ExceptionPtr) in
guard let result = JavaScriptCore.JSObjectCallAsFunction(ctx.context, self.value, nil, arguments.count, arguments.count == 0 ? nil : args, exception) else {
guard let result = JavaScriptCore.JSObjectCallAsFunction(ctx.context, self.value, this?.value, arguments.count, arguments.count == 0 ? nil : args, exception) else {
return JSValue(undefinedIn: ctx)
}

Expand Down Expand Up @@ -711,6 +784,27 @@ public func JSValue(string value: String, in context: JSContext) -> JSValue {
defer { JavaScriptCore.JSStringRelease(str) }
return JSValue(jsValueRef: JavaScriptCore.JSValueMakeString(context.context, str), in: context)
}

// workaround for inability to implement this as a convenience constructor due to needing local variables
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func JSValue(newAsyncFunctionIn context: JSContext, callback: @escaping (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) async throws -> JSValue) -> JSValue {
return JSValue(newFunctionIn: context, callback: { ctx, obj, args in
guard let promiseParts = ctx.createPromise() else {
return JSValue(undefinedIn: ctx)
}
let (promise, resolveFunction, rejectFunction) = promiseParts // SKIP 3-tuples do not handle referencing elements by name
Task {
do {
let result = try await callback(ctx, obj, args)
_ = try? resolveFunction.call(withArguments: [result])
} catch {
let errorValue = JSValue(object: String(describing: error), in: ctx)
_ = try? rejectFunction.call(withArguments: [errorValue])
}
}
return promise
})
}
#endif


Expand Down Expand Up @@ -782,6 +876,10 @@ public struct JSError: Error, CustomStringConvertible {
public typealias JSFunction = (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) throws -> JSValue
public typealias JSPromise = (promise: JSValue, resolveFunction: JSValue, rejectFunction: JSValue)

/// An async function definition, used when defining async callbacks that return Promises to JavaScript.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public typealias JSAsyncFunction = (_ ctx: JSContext, _ obj: JSValue?, _ args: [JSValue]) async throws -> JSValue

private struct _JSFunctionInfoHandle {
unowned let context: JSContext
let callback: JSFunction
Expand Down
2 changes: 0 additions & 2 deletions Sources/SkipScript/Skip/skip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
# contents:
# - block: 'repositories'
# contents:
# # this is where the android-jsc libraries are hosted
# - 'maven("https://maven.skip.tools")'

# the blocks to add to the build.gradle.kts
build:
Expand Down
11 changes: 11 additions & 0 deletions Tests/SkipScriptTests/Skip/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
These permissions will be merged into the unit test manifest, e.g.:
.build/plugins/outputs/skip-foundation/SkipFoundationTests/skipstone/SkipFoundation/.build/SkipFoundation/intermediates/packaged_manifests/debugUnitTest/AndroidManifest.xml
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
34 changes: 34 additions & 0 deletions Tests/SkipScriptTests/SkipContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ class SkipContextTests : XCTestCase {
XCTAssertEqual("true12X", ctx.evaluateScript("stringify(true, 1, 2, 'X')")?.toString())
}

func testAsyncStringArgsFunctionProperty() async throws {
let ctx = JSContext()
let asyncStringify = JSValue(newAsyncFunctionIn: ctx) { ctx, obj, args in
try await Task.sleep(nanoseconds: 10_000_000) // 10ms delay to simulate async work
return JSValue(string: args.compactMap({ $0.toString() }).joined(), in: ctx)
}

ctx.setObject(asyncStringify, forKeyedSubscript: "asyncStringify")

do {
let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify()"))
let resolved = try await ctx.awaitPromise(promise)
XCTAssertEqual("", resolved.toString())
}

do {
let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify('')"))
let resolved = try await ctx.awaitPromise(promise)
XCTAssertEqual("", resolved.toString())
}

do {
let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify('A', 'BC')"))
let resolved = try await ctx.awaitPromise(promise)
XCTAssertEqual("ABC", resolved.toString())
}

do {
let promise = try XCTUnwrap(ctx.evaluateScript("asyncStringify(true, 1, 2, 'X')"))
let resolved = try await ctx.awaitPromise(promise)
XCTAssertEqual("true12X", resolved.toString())
}
}

func testDoubleArgsFunctionProperty() throws {
let ctx = JSContext()
let sum = JSValue(newFunctionIn: ctx) { ctx, obj, args in
Expand Down
Loading