Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ab19ba9
BridgeJS: support imports of `Promise` JS as `async` Swift
MaxDesiatov Mar 23, 2026
d741dce
E2e testing of bridging Promise<interface> returns
MaxDesiatov Mar 23, 2026
d0b207c
fix formatting
MaxDesiatov Mar 23, 2026
2b8bcaf
`JSTypedClosure`-based approach
MaxDesiatov Mar 24, 2026
7864617
Clean up `BridgeJSLink`
MaxDesiatov Mar 24, 2026
0e84003
Fix missing `import _Concurrency`
MaxDesiatov Mar 24, 2026
3044b3a
Fix formatting
MaxDesiatov Mar 24, 2026
8d4472e
Use `JSTypedClosure` without wrapping the result value
MaxDesiatov Mar 24, 2026
4244627
Merge branch 'main' into maxd/async-bridgejs
MaxDesiatov Mar 24, 2026
61fe95e
Make closure parameters as `sending`
MaxDesiatov Mar 24, 2026
cdb8013
Check more stack ABI types
MaxDesiatov Mar 26, 2026
695a715
Add support for `async` in `@JSFunction`
MaxDesiatov Mar 26, 2026
67ff8e6
Use namespaced import
MaxDesiatov Mar 26, 2026
ae831f1
Fix missing `fetchWeatherData`
MaxDesiatov Mar 26, 2026
a250ab3
Bring back `fetchWeatherData`
MaxDesiatov Mar 26, 2026
6d36763
Regenerate `fetchWeatherData` bridging code
MaxDesiatov Mar 26, 2026
b472133
BridgeJS: Centralize closure sig collection in BridgeSkeletonWalker
kateinoigakukun Mar 26, 2026
14561d4
BridgeJS: Stop spreading isAsync handling outside of CallJSEmission
kateinoigakukun Mar 26, 2026
4bfdfe7
BridgeJS: Remove error-prone default effects in thunk generation
kateinoigakukun Mar 26, 2026
a03b585
BridgeJSLink: Centralize async handling in ImportedThunkBuilder
kateinoigakukun Mar 26, 2026
22e7434
BridgeJS: Remove reundant returnType from `call` family of methods in…
kateinoigakukun Mar 26, 2026
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
15 changes: 15 additions & 0 deletions Benchmarks/Sources/Generated/JavaScript/BridgeJS.json
Original file line number Diff line number Diff line change
Expand Up @@ -2839,6 +2839,11 @@
{
"functions" : [
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "benchmarkHelperNoop",
"parameters" : [

Expand All @@ -2850,6 +2855,11 @@
}
},
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "benchmarkHelperNoopWithNumber",
"parameters" : [
{
Expand All @@ -2868,6 +2878,11 @@
}
},
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "benchmarkRunner",
"parameters" : [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@
{
"functions" : [
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "createTS2Swift",
"parameters" : [

Expand All @@ -260,6 +265,11 @@
],
"methods" : [
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "convert",
"parameters" : [
{
Expand Down
84 changes: 73 additions & 11 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,44 @@ public struct ImportTS {
}
}

func liftAsyncReturnValue(originalReturnType: BridgeType) {
// For async imports, the extern function returns a Promise object ID (i32).
// We wrap it in JSPromise, await the resolved value, then lift to the target type.
abiReturnType = .i32
body.write(
"let promise = JSPromise(unsafelyWrapping: JSObject(id: UInt32(bitPattern: ret)))"
)
if originalReturnType == .void {
body.write("_ = try await promise.value")
} else {
body.write("let resolved = try await promise.value")
let liftExpr: String
switch originalReturnType {
case .double:
liftExpr = "Double(resolved.number!)"
case .float:
liftExpr = "Float(resolved.number!)"
case .integer:
liftExpr = "Int(resolved.number!)"
case .string:
liftExpr = "resolved.string!"
case .bool:
liftExpr = "resolved.boolean!"
case .jsObject(let name):
if let name {
liftExpr = "\(name)(unsafelyWrapping: resolved.object!)"
} else {
liftExpr = "resolved.object!"
}
case .jsValue:
liftExpr = "resolved"
default:
liftExpr = "resolved.object!"
}
body.write("return \(liftExpr)")
}
}

func assignThis(returnType: BridgeType) {
guard case .jsObject = returnType else {
preconditionFailure("assignThis can only be called with a jsObject return type")
Expand All @@ -299,9 +337,13 @@ public struct ImportTS {
return "\(raw: printer.lines.joined(separator: "\n"))"
}

func renderThunkDecl(name: String, parameters: [Parameter], returnType: BridgeType) -> DeclSyntax {
func renderThunkDecl(
name: String,
parameters: [Parameter],
returnType: BridgeType,
effects: Effects = Effects(isAsync: false, isThrows: true)
) -> DeclSyntax {
let printer = CodeFragmentPrinter()
let effects = Effects(isAsync: false, isThrows: true)
let signature = SwiftSignatureBuilder.buildFunctionSignature(
parameters: parameters,
returnType: returnType,
Expand Down Expand Up @@ -359,22 +401,30 @@ public struct ImportTS {
_ function: ImportedFunctionSkeleton,
topLevelDecls: inout [DeclSyntax]
) throws -> [DeclSyntax] {
// For async functions, the ABI return type is always jsObject (the Promise).
// We tell CallJSEmission that the return type is jsObject so it captures the return value.
let abiReturnType: BridgeType = function.effects.isAsync ? .jsObject(nil) : function.returnType
let builder = try CallJSEmission(
moduleName: moduleName,
abiName: function.abiName(context: nil),
returnType: function.returnType
returnType: abiReturnType
)
for param in function.parameters {
try builder.lowerParameter(param: param)
}
try builder.call()
try builder.liftReturnValue()
if function.effects.isAsync {
builder.liftAsyncReturnValue(originalReturnType: function.returnType)
} else {
try builder.liftReturnValue()
}
topLevelDecls.append(builder.renderImportDecl())
return [
builder.renderThunkDecl(
name: Self.thunkName(function: function),
parameters: function.parameters,
returnType: function.returnType
returnType: function.returnType,
effects: function.effects
)
.with(\.leadingTrivia, Self.renderDocumentation(documentation: function.documentation))
]
Expand All @@ -385,41 +435,53 @@ public struct ImportTS {
var decls: [DeclSyntax] = []

func renderMethod(method: ImportedFunctionSkeleton) throws -> [DeclSyntax] {
let abiReturnType: BridgeType = method.effects.isAsync ? .jsObject(nil) : method.returnType
let builder = try CallJSEmission(
moduleName: moduleName,
abiName: method.abiName(context: type),
returnType: method.returnType
returnType: abiReturnType
)
try builder.lowerParameter(param: selfParameter)
for param in method.parameters {
try builder.lowerParameter(param: param)
}
try builder.call()
try builder.liftReturnValue()
if method.effects.isAsync {
builder.liftAsyncReturnValue(originalReturnType: method.returnType)
} else {
try builder.liftReturnValue()
}
topLevelDecls.append(builder.renderImportDecl())
return [
builder.renderThunkDecl(
name: Self.thunkName(type: type, method: method),
parameters: [selfParameter] + method.parameters,
returnType: method.returnType
returnType: method.returnType,
effects: method.effects
)
]
}

func renderStaticMethod(method: ImportedFunctionSkeleton) throws -> [DeclSyntax] {
let abiName = method.abiName(context: type, operation: "static")
let builder = try CallJSEmission(moduleName: moduleName, abiName: abiName, returnType: method.returnType)
let abiReturnType: BridgeType = method.effects.isAsync ? .jsObject(nil) : method.returnType
let builder = try CallJSEmission(moduleName: moduleName, abiName: abiName, returnType: abiReturnType)
for param in method.parameters {
try builder.lowerParameter(param: param)
}
try builder.call()
try builder.liftReturnValue()
if method.effects.isAsync {
builder.liftAsyncReturnValue(originalReturnType: method.returnType)
} else {
try builder.liftReturnValue()
}
topLevelDecls.append(builder.renderImportDecl())
return [
builder.renderThunkDecl(
name: Self.thunkName(type: type, method: method),
parameters: method.parameters,
returnType: method.returnType
returnType: method.returnType,
effects: method.effects
)
]
}
Expand Down
12 changes: 9 additions & 3 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2067,7 +2067,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
let valueType: BridgeType
}

/// Validates effects (throws required, async not supported)
/// Validates effects (throws required, async only supported for @JSFunction)
private func validateEffects(
_ effects: FunctionEffectSpecifiersSyntax?,
node: some SyntaxProtocol,
Expand All @@ -2083,7 +2083,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
)
return nil
}
if effects.isAsync {
if effects.isAsync && attributeName != "JSFunction" {
errors.append(
DiagnosticError(
node: node,
Expand Down Expand Up @@ -2420,7 +2420,12 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
_ jsFunction: AttributeSyntax,
_ node: FunctionDeclSyntax,
) -> ImportedFunctionSkeleton? {
guard validateEffects(node.signature.effectSpecifiers, node: node, attributeName: "JSFunction") != nil
guard
let effects = validateEffects(
node.signature.effectSpecifiers,
node: node,
attributeName: "JSFunction"
)
else {
return nil
}
Expand All @@ -2446,6 +2451,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
from: from,
parameters: parameters,
returnType: returnType,
effects: effects,
documentation: nil
)
}
Expand Down
17 changes: 10 additions & 7 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,7 @@ public struct BridgeJSLink {
for method in type.methods {
let methodName = method.jsName ?? method.name
let methodSignature =
"\(renderTSPropertyName(methodName))\(renderTSSignature(parameters: method.parameters, returnType: method.returnType, effects: Effects(isAsync: false, isThrows: false)));"
"\(renderTSPropertyName(methodName))\(renderTSSignature(parameters: method.parameters, returnType: method.returnType, effects: method.effects));"
printer.write(methodSignature)
}

Expand Down Expand Up @@ -3124,21 +3124,23 @@ extension BridgeJSLink {
}
let jsName = function.jsName ?? function.name
let importRootExpr = function.from == .global ? "globalThis" : "imports"
// For async functions, the JS handler returns the Promise as a jsObject.
// The Swift side handles awaiting and lifting the resolved value.
let abiReturnType: BridgeType = function.effects.isAsync ? .jsObject(nil) : function.returnType
let returnExpr = try thunkBuilder.call(
name: jsName,
fromObjectExpr: importRootExpr,
returnType: function.returnType
returnType: abiReturnType
)
let funcLines = thunkBuilder.renderFunction(
name: function.abiName(context: nil),
returnExpr: returnExpr,
returnType: function.returnType
returnType: abiReturnType
)
let effects = Effects(isAsync: false, isThrows: false)
if function.from == nil {
importObjectBuilder.appendDts(
[
"\(renderTSPropertyName(jsName))\(renderTSSignature(parameters: function.parameters, returnType: function.returnType, effects: effects));"
"\(renderTSPropertyName(jsName))\(renderTSSignature(parameters: function.parameters, returnType: function.returnType, effects: function.effects));"
]
)
}
Expand Down Expand Up @@ -3337,11 +3339,12 @@ extension BridgeJSLink {
for param in method.parameters {
try thunkBuilder.liftParameter(param: param)
}
let returnExpr = try thunkBuilder.callMethod(name: method.jsName ?? method.name, returnType: method.returnType)
let abiReturnType: BridgeType = method.effects.isAsync ? .jsObject(nil) : method.returnType
let returnExpr = try thunkBuilder.callMethod(name: method.jsName ?? method.name, returnType: abiReturnType)
let funcLines = thunkBuilder.renderFunction(
name: method.abiName(context: context),
returnExpr: returnExpr,
returnType: method.returnType
returnType: abiReturnType
)
return (funcLines, [])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ public struct ImportedFunctionSkeleton: Codable {
public let from: JSImportFrom?
public let parameters: [Parameter]
public let returnType: BridgeType
public let effects: Effects
public let documentation: String?

public init(
Expand All @@ -931,13 +932,15 @@ public struct ImportedFunctionSkeleton: Codable {
from: JSImportFrom? = nil,
parameters: [Parameter],
returnType: BridgeType,
effects: Effects = Effects(isAsync: false, isThrows: true),
documentation: String? = nil
) {
self.name = name
self.jsName = jsName
self.from = from
self.parameters = parameters
self.returnType = returnType
self.effects = effects
self.documentation = documentation
}

Expand Down
33 changes: 27 additions & 6 deletions Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,8 @@ export class TypeProcessor {
const parameters = signature.getParameters();
const parameterNameMap = this.buildParameterNameMap(parameters);
const params = this.renderParameters(parameters, decl);
const returnType = this.visitType(signature.getReturnType(), decl);
const effects = this.renderEffects({ isAsync: false });
const { returnType, isAsync } = this.unwrapPromiseReturnType(signature.getReturnType(), decl);
const effects = this.renderEffects({ isAsync });
const annotation = this.renderMacroAnnotation("JSFunction", args);

this.emitDocComment(decl, { indent: "", parameterNameMap });
Expand Down Expand Up @@ -581,8 +581,8 @@ export class TypeProcessor {
const parameters = signature.getParameters();
const parameterNameMap = this.buildParameterNameMap(parameters);
const params = this.renderParameters(parameters, node);
const returnType = this.visitType(signature.getReturnType(), node);
const effects = this.renderEffects({ isAsync: false });
const { returnType, isAsync } = this.unwrapPromiseReturnType(signature.getReturnType(), node);
const effects = this.renderEffects({ isAsync });
const swiftFuncName = this.renderIdentifier(swiftName);

this.emitDocComment(node, { parameterNameMap });
Expand Down Expand Up @@ -1210,8 +1210,8 @@ export class TypeProcessor {
const parameters = signature.getParameters();
const parameterNameMap = this.buildParameterNameMap(parameters);
const params = this.renderParameters(parameters, node);
const returnType = this.visitType(signature.getReturnType(), node);
const effects = this.renderEffects({ isAsync: false });
const { returnType, isAsync } = this.unwrapPromiseReturnType(signature.getReturnType(), node);
const effects = this.renderEffects({ isAsync });
const swiftMethodName = this.renderIdentifier(swiftName);
const isStatic = node.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
Expand Down Expand Up @@ -1281,6 +1281,27 @@ export class TypeProcessor {
return parts.join(" ");
}

/**
* Check if a type is Promise<T> and extract the return type and async flag.
* @param {ts.Type} type - The return type to check
* @param {ts.Node} node - The node for type visiting context
* @returns {{ returnType: string, isAsync: boolean }}
* @private
*/
unwrapPromiseReturnType(type, node) {
if (isTypeReference(type)) {
const symbol = type.target?.getSymbol();
if (symbol?.name === "Promise") {
const typeArgs = this.checker.getTypeArguments(/** @type {ts.TypeReference} */ (type));
const innerType = typeArgs && typeArgs.length > 0
? this.visitType(typeArgs[0], node)
: "Void";
return { returnType: innerType, isAsync: true };
}
}
return { returnType: this.visitType(type, node), isAsync: false };
}

/**
* @param {ts.Node} node
* @returns {boolean}
Expand Down
Loading
Loading