kabiroberai / node-swift

Create Node modules in Swift
MIT License
416 stars 11 forks source link

Document NodeFunctions and NodePromises #24

Closed shirakaba closed 2 weeks ago

shirakaba commented 2 weeks ago

I'm trying to figure out how to use NodeFunctions and NodePromises, but there are few examples in the code and tests. I'm also rather novice at Swift, so could really use a documented example.

What I'd like to do, on the JS side:

// index.js
const { doHeavyTaskWithArgument } = require("./.build/Module.node");

try {
  const result = await doHeavyTaskWithArgument("abc");
  console.assert(result === "cba", "Expected string to be reversed");
} catch (error){
  console.error("Task failed", error);
}

In other words, I want to call a function with this interface:

interface NodeModule {
  doHeavyTaskWithArgument(arg: string): Promise<string>;
}

How would I express that in Swift? I can see how to accept an argument, but not how to resolve or reject a NodePromise.

import ScreenCaptureKit

#NodeModule(exports: [
  // Right now I'm using a NodeFunction, which allows me to accept an argument:
  "doHeavyTaskWithArgument": try NodeFunction { (arg: String) in
    // What would I write in order to return a NodePromise from here, which wraps the following function
    // that has a completion block?

    SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: true) { content, error in
      guard let content = content else {
        // How would I reject the NodePromise here?
        return
      }

      // How would I resolve the NodePromise here?
    }
  },
])
kabiroberai commented 2 weeks ago

There are two good options here:

  1. Construct a NodePromise yourself: this is quite similar to the approach you'd take in JS. Something like
#NodeModule(exports: [
    "doHeavyTaskWithArgument": try NodeFunction { (arg: String) in
        return NodePromise { deferred in
            SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: true) { content, error in
                guard let content = content else {
                    deferred(.failure(error!))
                    return
                }
                let output = // turn content into something compatible with JS
                deferred(.success(output))
            }
        }
    },
])
  1. Or better yet, stay in the async world in Swift: NodeSwift converts async Swift functions into JS functions that return promises. Fortunately, SCShareableContent already appears to provide async APIs. So you can do something like
#NodeModule(exports: [
    "doHeavyTaskWithArgument": try NodeFunction { (arg: String) async throws in
        let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
        let output = // ...
        return output
    },
])
shirakaba commented 2 weeks ago

Thanks so much for providing both examples; I'll definitely have a use for both!

I'll try it out now. Will either come back with follow-up questions or be able to close the issue altogether. 🙇

shirakaba commented 2 weeks ago

Tried out the second pattern and it worked great, thanks!

Follow-up question: if we define a NodeFunction, is it always exposed to JavaScript as a Promise, or only if the Swift function is marked as async here:

try NodeFunction { (arg: String) async throws in
---------------------------------^^^^^
kabiroberai commented 2 weeks ago

It's exposed as a Promise if and only if Swift resolves the function as async. This means either explicitly using async in the method signature and/or if there's an await in the method body.

kabiroberai commented 2 weeks ago

I'll definitely have a use for both

FWIW both of these patterns are mostly equivalent. If you're reliant on a function that uses the callback-passing style, you can bridge it to the async world by using withChecked[Throwing]Continuation. For example, if the async overload of excludingDesktopWindows didn't exist, you could instead do

#NodeModule(exports: [
    "doHeavyTaskWithArgument": try NodeFunction { (arg: String) async throws in
        let content = try await withCheckedThrowingContinuation { continuation in
            SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: true) { content, error in
                guard let content = content else {
                    continuation.resume(throwing: error!)
                    return
                }
                continuation.resume(returning: content)
            }
        }
        let output = // ...
        return output
    },
])

One way to think about this is that withChecked[Throwing]Continuation is basically Swift's equivalent of the Promise constructor/util.promisify, so this is almost like a mix of the explicit NodePromise approach and the implicit async approach.

shirakaba commented 2 weeks ago

It's exposed as a Promise if and only if Swift resolves the function as async. This means either explicitly using async in the method signature and/or if there's an await in the method body.

Got it!

And thanks so much! I was struggling with withChecked[Throwing]Continuation but this code sample makes a lot of sense. Apart from helping me use node-swift, this has all been really helpful for my Swift 😅