From d9d6c56949ce89bcdfc5ea993ad7ae4420cc0e42 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 26 Jan 2026 15:39:19 +0900 Subject: [PATCH] Implemented BridgeJS support for using `@JSClass`-imported TS/JS types in exported APIs by treating `@JSClass` **classes/actors** as `.jsObject` (previously they were misclassified as Swift heap objects, producing JS like `Foo.__construct(...)`). - Fix: `Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift` - Regression test + snapshots: `Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/ImportedTypeInExportedInterface.swift`, `Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.js`, `Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.d.ts`, `Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.swift`, `Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.json` - Verified with `swift test --package-path ./Plugins/BridgeJS` --- .../BridgeJSCore/SwiftToSkeleton.swift | 10 + .../ImportedTypeInExportedInterface.swift | 7 + ...mportedTypeInExportedInterface.Export.d.ts | 23 ++ .../ImportedTypeInExportedInterface.Export.js | 233 ++++++++++++++++++ .../ImportedTypeInExportedInterface.json | 34 +++ .../ImportedTypeInExportedInterface.swift | 24 ++ .../BridgeJSRuntimeTests/ExportAPITests.swift | 11 +- .../Generated/BridgeJS.swift | 61 +++++ .../Generated/JavaScript/BridgeJS.json | 55 +++++ Tests/prelude.mjs | 12 + 10 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/ImportedTypeInExportedInterface.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.js create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.json create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.swift diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 6628a3043..f18788e7d 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -235,6 +235,16 @@ public final class SwiftToSkeleton { return nil } let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text) + + // A type annotated with @JSClass is a JavaScript object wrapper (imported), + // even if it is declared as a Swift class. + if let classDecl = typeDecl.as(ClassDeclSyntax.self), classDecl.attributes.hasAttribute(name: "JSClass") { + return .jsObject(swiftCallName) + } + if let actorDecl = typeDecl.as(ActorDeclSyntax.self), actorDecl.attributes.hasAttribute(name: "JSClass") { + return .jsObject(swiftCallName) + } + return .swiftHeapObject(swiftCallName) } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/ImportedTypeInExportedInterface.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/ImportedTypeInExportedInterface.swift new file mode 100644 index 000000000..9e3524284 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/ImportedTypeInExportedInterface.swift @@ -0,0 +1,7 @@ +@JSClass class Foo { + @JSFunction init() throws(JSException) +} + +@JS func makeFoo() throws(JSException) -> Foo { + return try Foo() +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.d.ts new file mode 100644 index 000000000..654b4bc04 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.d.ts @@ -0,0 +1,23 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export interface Foo { +} +export type Exports = { + makeFoo(): Foo; +} +export type Imports = { + Foo: { + new(): Foo; + } +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.js new file mode 100644 index 000000000..93c23a0db --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/ImportedTypeInExportedInterface.Export.js @@ -0,0 +1,233 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let tmpRetTag; + let tmpRetStrings = []; + let tmpRetInts = []; + let tmpRetF32s = []; + let tmpRetF64s = []; + let tmpParamInts = []; + let tmpParamF32s = []; + let tmpParamF64s = []; + let tmpRetPointers = []; + let tmpParamPointers = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + const imports = options.getImports(importsContext); + bjs["swift_js_return_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + return swift.memory.retain(textDecoder.decode(bytes)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr, len); + target.set(tmpRetBytes); + tmpRetBytes = undefined; + } + bjs["swift_js_throw"] = function(id) { + tmpRetException = swift.memory.retainByRef(id); + } + bjs["swift_js_retain"] = function(id) { + return swift.memory.retainByRef(id); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_tag"] = function(tag) { + tmpRetTag = tag; + } + bjs["swift_js_push_int"] = function(v) { + tmpRetInts.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + tmpRetF32s.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + tmpRetF64s.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + const value = textDecoder.decode(bytes); + tmpRetStrings.push(value); + } + bjs["swift_js_pop_param_int32"] = function() { + return tmpParamInts.pop(); + } + bjs["swift_js_pop_param_f32"] = function() { + return tmpParamF32s.pop(); + } + bjs["swift_js_pop_param_f64"] = function() { + return tmpParamF64s.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + tmpRetPointers.push(pointer); + } + bjs["swift_js_pop_param_pointer"] = function() { + return tmpParamPointers.pop(); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_Foo_init"] = function bjs_Foo_init() { + try { + return swift.memory.retain(new imports.Foo()); + } catch (error) { + setException(error); + return 0 + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + makeFoo: function bjs_makeFoo() { + const ret = instance.exports.bjs_makeFoo(); + const ret1 = swift.memory.getObject(ret); + swift.memory.release(ret); + if (tmpRetException) { + const error = swift.memory.getObject(tmpRetException); + swift.memory.release(tmpRetException); + tmpRetException = undefined; + throw error; + } + return ret1; + }, + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.json new file mode 100644 index 000000000..6ee073ca8 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.json @@ -0,0 +1,34 @@ +{ + "classes" : [ + + ], + "enums" : [ + + ], + "exposeToGlobal" : false, + "functions" : [ + { + "abiName" : "bjs_makeFoo", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "makeFoo", + "parameters" : [ + + ], + "returnType" : { + "jsObject" : { + "_0" : "Foo" + } + } + } + ], + "protocols" : [ + + ], + "structs" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.swift new file mode 100644 index 000000000..0bebd4e73 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/ImportedTypeInExportedInterface.swift @@ -0,0 +1,24 @@ +@_expose(wasm, "bjs_makeFoo") +@_cdecl("bjs_makeFoo") +public func _bjs_makeFoo() -> Int32 { + #if arch(wasm32) + do { + let ret = try makeFoo() + return ret.bridgeJSLowerReturn() + } catch let error { + if let error = error.thrownValue.object { + withExtendedLifetime(error) { + _swift_js_throw(Int32(bitPattern: $0.id)) + } + } else { + let jsError = JSError(message: String(describing: error)) + withExtendedLifetime(jsError.jsObject) { + _swift_js_throw(Int32(bitPattern: $0.id)) + } + } + return 0 + } + #else + fatalError("Only available on WebAssembly") + #endif +} \ No newline at end of file diff --git a/Tests/BridgeJSRuntimeTests/ExportAPITests.swift b/Tests/BridgeJSRuntimeTests/ExportAPITests.swift index 286c9a380..0df421df6 100644 --- a/Tests/BridgeJSRuntimeTests/ExportAPITests.swift +++ b/Tests/BridgeJSRuntimeTests/ExportAPITests.swift @@ -1,5 +1,5 @@ import XCTest -import JavaScriptKit +@_spi(Experimental) import JavaScriptKit import JavaScriptEventLoop @_extern(wasm, module: "BridgeJSRuntimeTests", name: "runJsWorks") @@ -33,6 +33,15 @@ func runJsWorks() -> Void return v } +@JSClass struct Foo { + @JSGetter var value: String + @JSFunction init(_ value: String) throws(JSException) +} + +@JS func makeImportedFoo(value: String) throws(JSException) -> Foo { + return try Foo(value) +} + struct TestError: Error { let message: String } diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 1b39e77b8..7134003c0 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -2560,6 +2560,31 @@ public func _bjs_roundTripJSObject(_ v: Int32) -> Int32 { #endif } +@_expose(wasm, "bjs_makeImportedFoo") +@_cdecl("bjs_makeImportedFoo") +public func _bjs_makeImportedFoo(_ valueBytes: Int32, _ valueLength: Int32) -> Int32 { + #if arch(wasm32) + do { + let ret = try makeImportedFoo(value: String.bridgeJSLiftParameter(valueBytes, valueLength)) + return ret.bridgeJSLowerReturn() + } catch let error { + if let error = error.thrownValue.object { + withExtendedLifetime(error) { + _swift_js_throw(Int32(bitPattern: $0.id)) + } + } else { + let jsError = JSError(message: String(describing: error)) + withExtendedLifetime(jsError.jsObject) { + _swift_js_throw(Int32(bitPattern: $0.id)) + } + } + return 0 + } + #else + fatalError("Only available on WebAssembly") + #endif +} + @_expose(wasm, "bjs_throwsSwiftError") @_cdecl("bjs_throwsSwiftError") public func _bjs_throwsSwiftError(_ shouldThrow: Int32) -> Void { @@ -6166,6 +6191,42 @@ fileprivate func _bjs_Container_wrap(_ pointer: UnsafeMutableRawPointer) -> Int3 } #endif +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_Foo_init") +fileprivate func bjs_Foo_init(_ value: Int32) -> Int32 +#else +fileprivate func bjs_Foo_init(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_Foo_value_get") +fileprivate func bjs_Foo_value_get(_ self: Int32) -> Int32 +#else +fileprivate func bjs_Foo_value_get(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$Foo_init(_ value: String) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_Foo_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$Foo_value_get(_ self: JSObject) throws(JSException) -> String { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_Foo_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + #if arch(wasm32) @_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsRoundTripVoid") fileprivate func bjs_jsRoundTripVoid() -> Void diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 727508f3c..6127958ea 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -4729,6 +4729,31 @@ } } }, + { + "abiName" : "bjs_makeImportedFoo", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "makeImportedFoo", + "parameters" : [ + { + "label" : "value", + "name" : "value", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "jsObject" : { + "_0" : "Foo" + } + } + }, { "abiName" : "bjs_throwsSwiftError", "effects" : { @@ -8928,7 +8953,37 @@ ], "types" : [ + { + "constructor" : { + "parameters" : [ + { + "name" : "value", + "type" : { + "string" : { + } + } + } + ] + }, + "getters" : [ + { + "name" : "value", + "type" : { + "string" : { + + } + } + } + ], + "methods" : [ + + ], + "name" : "Foo", + "setters" : [ + + ] + } ] }, { diff --git a/Tests/prelude.mjs b/Tests/prelude.mjs index a5611523c..5363cfd25 100644 --- a/Tests/prelude.mjs +++ b/Tests/prelude.mjs @@ -64,6 +64,7 @@ export async function setupOptions(options, context) { this.name = name; } }, + Foo: ImportedFoo, runAsyncWorks: async () => { const exports = importsContext.getExports(); if (!exports) { @@ -100,6 +101,13 @@ export async function setupOptions(options, context) { import assert from "node:assert"; +class ImportedFoo { + /** @param {string} value */ + constructor(value) { + this.value = value; + } +} + /** @param {import('./../.build/plugins/PackageToJS/outputs/PackageTests/bridge-js.d.ts').Exports} exports */ function BridgeJSRuntimeTests_runJsWorks(instance, exports) { exports.roundTripVoid(); @@ -173,6 +181,10 @@ function BridgeJSRuntimeTests_runJsWorks(instance, exports) { g.release(); + const foo = exports.makeImportedFoo("hello"); + assert.ok(foo instanceof ImportedFoo); + assert.equal(foo.value, "hello"); + // Test PropertyHolder with various types const testObj = { testProp: "test" }; const sibling = new exports.SimplePropertyHolder(999);