From ea82047f8991580fd4d56f0d56a8b84313175f63 Mon Sep 17 00:00:00 2001 From: William Taylor Date: Tue, 10 Mar 2026 17:25:47 +1100 Subject: [PATCH 1/2] BridgeJS: Support throwing any error conforming to ConvertibleToJSException --- .../PlayBridgeJS/Generated/BridgeJS.swift | 11 +- .../Sources/BridgeJSCore/ExportSwift.swift | 36 ++- .../BridgeJSCore/SwiftToSkeleton.swift | 13 +- .../Inputs/MacroSwift/AsyncThrows.swift | 7 + .../BridgeJSCodegenTests/AsyncThrows.json | 54 +++++ .../BridgeJSCodegenTests/AsyncThrows.swift | 33 +++ .../EnumNamespace.Global.swift | 11 +- .../BridgeJSCodegenTests/EnumNamespace.swift | 11 +- .../ImportedTypeInExportedInterface.swift | 11 +- .../BridgeJSCodegenTests/Throws.swift | 11 +- .../BridgeJSLinkTests/AsyncThrows.d.ts | 19 ++ .../BridgeJSLinkTests/AsyncThrows.js | 228 ++++++++++++++++++ .../JavaScriptKit/BridgeJSIntrinsics.swift | 32 +++ .../ConvertibleToJSException.swift | 6 + .../Exporting-Swift/Exporting-Swift-Class.md | 3 +- .../Exporting-Swift-Function.md | 28 ++- Sources/JavaScriptKit/JSException.swift | 4 + .../BridgeJSRuntimeTests/ExportAPITests.swift | 9 + .../Generated/BridgeJS.swift | 114 ++------- .../Generated/JavaScript/BridgeJS.json | 17 ++ Tests/prelude.mjs | 9 + 21 files changed, 491 insertions(+), 176 deletions(-) create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/AsyncThrows.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.json create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.js create mode 100644 Sources/JavaScriptKit/ConvertibleToJSException.swift diff --git a/Examples/PlayBridgeJS/Sources/PlayBridgeJS/Generated/BridgeJS.swift b/Examples/PlayBridgeJS/Sources/PlayBridgeJS/Generated/BridgeJS.swift index f1baf3aa1..cb12abcf5 100644 --- a/Examples/PlayBridgeJS/Sources/PlayBridgeJS/Generated/BridgeJS.swift +++ b/Examples/PlayBridgeJS/Sources/PlayBridgeJS/Generated/BridgeJS.swift @@ -182,16 +182,7 @@ public func _bjs_PlayBridgeJS_updateDetailed(_ _self: UnsafeMutableRawPointer, _ let ret = try PlayBridgeJS.bridgeJSLiftParameter(_self).updateDetailed(swiftSource: String.bridgeJSLiftParameter(swiftSourceBytes, swiftSourceLength), dtsSource: String.bridgeJSLiftParameter(dtsSourceBytes, dtsSourceLength)) 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)) - } - } + error.bridgeJSLowerThrow() return } #else diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift index dbe4a1312..6a657c1fc 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift @@ -318,20 +318,25 @@ public class ExportSwift { func render(abiName: String) -> DeclSyntax { let body: CodeBlockItemListSyntax - if effects.isAsync { + if effects.isAsync, effects.isThrows { // Explicit closure type annotation needed when throws is present // so Swift infers throws(JSException) instead of throws(any Error) // See: https://github.com/swiftlang/swift/issues/76165 - let closureHead: String - if effects.isThrows { - let hasReturn = self.body.contains { $0.description.contains("return ") } - let ret = hasReturn ? " -> JSValue" : "" - closureHead = " () async throws(JSException)\(ret) in" - } else { - closureHead = "" - } + let hasReturn = self.body.contains { $0.description.contains("return ") } + let ret = hasReturn ? " -> JSValue" : "" body = """ - let ret = JSPromise.async {\(raw: closureHead) + let ret = JSPromise.async { () async throws(JSException)\(raw: ret) in + do { + \(CodeBlockItemListSyntax(self.body)) + } catch let error { + throw error.bridgeJSLowerThrowAsync() + } + }.jsObject + return ret.bridgeJSLowerReturn() + """ + } else if effects.isAsync { + body = """ + let ret = JSPromise.async { \(CodeBlockItemListSyntax(self.body)) }.jsObject return ret.bridgeJSLowerReturn() @@ -341,16 +346,7 @@ public class ExportSwift { do { \(CodeBlockItemListSyntax(self.body)) } 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)) - } - } + error.bridgeJSLowerThrow() \(raw: returnPlaceholderStmt()) } """ diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 8f1b3fa35..745d50cb6 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -1122,18 +1122,11 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor { let isAsync = signature.effectSpecifiers?.asyncSpecifier != nil var isThrows = false if let throwsClause: ThrowsClauseSyntax = signature.effectSpecifiers?.throwsClause { - // Limit the thrown type to JSException for now - guard let thrownType = throwsClause.type else { + // Require typed throws for now + guard throwsClause.type != nil else { diagnose( node: throwsClause, - message: "Thrown type is not specified, only JSException is supported for now" - ) - return nil - } - guard thrownType.trimmedDescription == "JSException" else { - diagnose( - node: throwsClause, - message: "Only JSException is supported for thrown type, got \(thrownType.trimmedDescription)" + message: "Thrown type must be specified. Only JSException or any error conforming to ConvertibleToJSException is supported" ) return nil } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/AsyncThrows.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/AsyncThrows.swift new file mode 100644 index 000000000..447cda320 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/AsyncThrows.swift @@ -0,0 +1,7 @@ +@JS func asyncThrowsVoid() async throws(JSException) { + throw JSException(message: "TestError") +} + +@JS func asyncThrowsWithResult() async throws(JSException) -> Int { + return 1 +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.json new file mode 100644 index 000000000..0d997e857 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.json @@ -0,0 +1,54 @@ +{ + "exported" : { + "classes" : [ + + ], + "enums" : [ + + ], + "exposeToGlobal" : false, + "functions" : [ + { + "abiName" : "bjs_asyncThrowsVoid", + "effects" : { + "isAsync" : true, + "isStatic" : false, + "isThrows" : true + }, + "name" : "asyncThrowsVoid", + "parameters" : [ + + ], + "returnType" : { + "void" : { + + } + } + }, + { + "abiName" : "bjs_asyncThrowsWithResult", + "effects" : { + "isAsync" : true, + "isStatic" : false, + "isThrows" : true + }, + "name" : "asyncThrowsWithResult", + "parameters" : [ + + ], + "returnType" : { + "int" : { + + } + } + } + ], + "protocols" : [ + + ], + "structs" : [ + + ] + }, + "moduleName" : "TestModule" +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.swift new file mode 100644 index 000000000..93772177e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/AsyncThrows.swift @@ -0,0 +1,33 @@ +@_expose(wasm, "bjs_asyncThrowsVoid") +@_cdecl("bjs_asyncThrowsVoid") +public func _bjs_asyncThrowsVoid() -> Int32 { + #if arch(wasm32) + let ret = JSPromise.async { () async throws(JSException) in + do { + try await asyncThrowsVoid() + } catch let error { + throw error.bridgeJSLowerThrowAsync() + } + }.jsObject + return ret.bridgeJSLowerReturn() + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_asyncThrowsWithResult") +@_cdecl("bjs_asyncThrowsWithResult") +public func _bjs_asyncThrowsWithResult() -> Int32 { + #if arch(wasm32) + let ret = JSPromise.async { () async throws(JSException) -> JSValue in + do { + return try await asyncThrowsWithResult().jsValue + } catch let error { + throw error.bridgeJSLowerThrowAsync() + } + }.jsObject + return ret.bridgeJSLowerReturn() + #else + fatalError("Only available on WebAssembly") + #endif +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.Global.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.Global.swift index d4dfa57a9..056a91ca0 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.Global.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.Global.swift @@ -112,16 +112,7 @@ public func _bjs_Services_Graph_GraphOperations_static_validate(_ graphId: Int32 let ret = try GraphOperations.validate(graphId: Int.bridgeJSLiftParameter(graphId)) 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.swift index d4dfa57a9..056a91ca0 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/EnumNamespace.swift @@ -112,16 +112,7 @@ public func _bjs_Services_Graph_GraphOperations_static_validate(_ graphId: Int32 let ret = try GraphOperations.validate(graphId: Int.bridgeJSLiftParameter(graphId)) 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/ImportedTypeInExportedInterface.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/ImportedTypeInExportedInterface.swift index f3c3f2fc1..25f226d3b 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/ImportedTypeInExportedInterface.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/ImportedTypeInExportedInterface.swift @@ -54,16 +54,7 @@ public func _bjs_makeFoo() -> Int32 { 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/Throws.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/Throws.swift index 37f6d9c96..6492160ea 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/Throws.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/Throws.swift @@ -5,16 +5,7 @@ public func _bjs_throwsSomething() -> Void { do { try throwsSomething() } 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)) - } - } + error.bridgeJSLowerThrow() return } #else diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.d.ts new file mode 100644 index 000000000..56a32372c --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.d.ts @@ -0,0 +1,19 @@ +// 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 type Exports = { + asyncThrowsVoid(): Promise; + asyncThrowsWithResult(): Promise; +} +export type Imports = { +} +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/AsyncThrows.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.js new file mode 100644 index 000000000..8f64c9a47 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/AsyncThrows.js @@ -0,0 +1,228 @@ +// 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; + let decodeString; + 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 strStack = []; + let i32Stack = []; + let f32Stack = []; + let f64Stack = []; + let ptrStack = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + bjs["swift_js_return_string"] = function(ptr, len) { + tmpRetString = decodeString(ptr, len); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + swift.memory.release(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + return swift.memory.retain(decodeString(ptr, len)); + } + 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_i32"] = function(v) { + i32Stack.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + f32Stack.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + f64Stack.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const value = decodeString(ptr, len); + strStack.push(value); + } + bjs["swift_js_pop_i32"] = function() { + return i32Stack.pop(); + } + bjs["swift_js_pop_f32"] = function() { + return f32Stack.pop(); + } + bjs["swift_js_pop_f64"] = function() { + return f64Stack.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + ptrStack.push(pointer); + } + bjs["swift_js_pop_pointer"] = function() { + return ptrStack.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 { + tmpRetString = decodeString(ptr, len); + } + } + 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; + } + bjs["swift_js_closure_unregister"] = function(funcRef) {} + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + decodeString = (ptr, len) => { const bytes = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); return textDecoder.decode(bytes); } + + 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 = { + asyncThrowsVoid: function bjs_asyncThrowsVoid() { + const ret = instance.exports.bjs_asyncThrowsVoid(); + 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; + }, + asyncThrowsWithResult: function bjs_asyncThrowsWithResult() { + const ret = instance.exports.bjs_asyncThrowsWithResult(); + 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/Sources/JavaScriptKit/BridgeJSIntrinsics.swift b/Sources/JavaScriptKit/BridgeJSIntrinsics.swift index f2b2b6c34..412b05825 100644 --- a/Sources/JavaScriptKit/BridgeJSIntrinsics.swift +++ b/Sources/JavaScriptKit/BridgeJSIntrinsics.swift @@ -33,6 +33,38 @@ private func _swift_js_throw_extern(_ id: Int32) { _swift_js_throw_extern(id) } +extension ConvertibleToJSException { + @_spi(BridgeJS) public func bridgeJSLowerThrow() { + let thrownValue = self.jsException.thrownValue + if let object = thrownValue.object { + withExtendedLifetime(object) { + _swift_js_throw(Int32(bitPattern: $0.id)) + } + } else { + let jsError = JSError(message: "\(thrownValue)") + withExtendedLifetime(jsError.jsObject) { + _swift_js_throw(Int32(bitPattern: $0.id)) + } + } + } + + @_spi(BridgeJS) public func bridgeJSLowerThrowAsync() -> JSException { + jsException + } +} + +extension Error { + @available(*, unavailable, message: "It is only possible to throw a JSException or an Error which conforms to ConvertibleToJSException") + @_spi(BridgeJS) public func bridgeJSLowerThrow() { + fatalError() + } + + @available(*, unavailable, message: "It is only possible to throw a JSException or an Error which conforms to ConvertibleToJSException") + @_spi(BridgeJS) public func bridgeJSLowerThrowAsync() -> JSException { + fatalError() + } +} + /// Retrieves and clears any pending JavaScript exception. /// /// This function checks for any JavaScript exceptions that were thrown during diff --git a/Sources/JavaScriptKit/ConvertibleToJSException.swift b/Sources/JavaScriptKit/ConvertibleToJSException.swift new file mode 100644 index 000000000..3f98743d1 --- /dev/null +++ b/Sources/JavaScriptKit/ConvertibleToJSException.swift @@ -0,0 +1,6 @@ +/// Errors that can be converted to a JSException. +/// +/// If an error conforms to this protocol, it is possible to throw it from a `@JS` method. +public protocol ConvertibleToJSException: Error { + var jsException: JSException { get } +} diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Class.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Class.md index 9cd4a2224..752b52096 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Class.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Class.md @@ -94,7 +94,8 @@ This differs from structs, which use copy semantics and transfer data by value. |:--------------|:-------| | Initializers: `init()` | ✅ | | Initializers that throw JSException: `init() throws(JSException)` | ✅ | -| Initializers that throw any exception: `init() throws` | ❌ | +| Initializers that throw typed error conforming to `ConvertibleToJSException` | ✅ | +| Untyped throws initializers: `init() throws` | ❌ | | Async initializers: `init() async` | ❌ | | Deinitializers: `deinit` | ✅ | | Stored properties: `var`, `let` (with `willSet`, `didSet`) | ✅ | diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Function.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Function.md index c26841041..b554640ad 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Function.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Function.md @@ -39,14 +39,31 @@ export type Exports = { ### Throwing functions -Swift functions can throw JavaScript errors using `throws(JSException)`. +Swift functions can throw JavaScript errors using `throws(JSException)`: ```swift import JavaScriptKit @JS public func findUser(id: Int) throws(JSException) -> String { if id <= 0 { - throw JSException(JSError(message: "Invalid ID").jsValue) + throw JSException(message: "Invalid ID") + } + return "User_\(id)" +} +``` + +You can also use custom error types by conforming to `ConvertibleToJSException`: + +```swift +import JavaScriptKit + +struct InvalidIDError: ConvertibleToJSException { + var jsException: JSException { JSException(message: "Invalid ID") } +} + +@JS public func findUser(id: Int) throws(InvalidIDError) -> String { + if id <= 0 { + throw InvalidIDError() } return "User_\(id)" } @@ -72,7 +89,7 @@ export type Exports = { ``` Notes: -- Only `throws(JSException)` is supported. Plain `throws` is not supported. +- Typed throws is required. Plain `throws` is not supported. - Thrown values are surfaced to JS as normal JS exceptions. ### Async functions @@ -145,8 +162,9 @@ export type Exports = { | `@JS class` parameter/result type | ✅ | | `@JS enum` parameter/result type | ✅ | | `JSObject` parameter/result type | ✅ | -| Throwing JS exception: `func x() throws(JSException)` | ✅ | -| Throwing any exception: `func x() throws` | ❌ | +| Throwing JSException: `func x() throws(JSException)` | ✅ | +| Throwing typed error conforming to `ConvertibleToJSException` | ✅ | +| Untyped throws: `func x() throws` | ❌ | | Async methods: `func x() async` | ✅ | | Generics | ❌ | | Opaque types: `func x() -> some P`, `func y(_: some P)` | ❌ | diff --git a/Sources/JavaScriptKit/JSException.swift b/Sources/JavaScriptKit/JSException.swift index 4d95e207d..d86264892 100644 --- a/Sources/JavaScriptKit/JSException.swift +++ b/Sources/JavaScriptKit/JSException.swift @@ -56,3 +56,7 @@ public struct JSException: Error, Equatable, CustomStringConvertible { self.init(JSError(message: message).jsValue) } } + +extension JSException: ConvertibleToJSException { + public var jsException: JSException { self } +} diff --git a/Tests/BridgeJSRuntimeTests/ExportAPITests.swift b/Tests/BridgeJSRuntimeTests/ExportAPITests.swift index c8ebbc8ce..d9e43c027 100644 --- a/Tests/BridgeJSRuntimeTests/ExportAPITests.swift +++ b/Tests/BridgeJSRuntimeTests/ExportAPITests.swift @@ -85,6 +85,15 @@ struct TestError: Error { let message: String } +struct StringError: ConvertibleToJSException { + let message: String + var jsException: JSException { JSException(message: message) } +} + +@JS func throwsStringError() throws(StringError) { + throw StringError(message: "Custom string error") +} + @JS func throwsSwiftError(shouldThrow: Bool) throws(JSException) -> Void { if shouldThrow { throw JSException(JSError(message: "TestError").jsValue) diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 13aab1455..c26ba8e2b 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -5416,16 +5416,7 @@ public func _bjs_makeImportedFoo(_ valueBytes: Int32, _ valueLength: Int32) -> I 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else @@ -5433,6 +5424,21 @@ public func _bjs_makeImportedFoo(_ valueBytes: Int32, _ valueLength: Int32) -> I #endif } +@_expose(wasm, "bjs_throwsStringError") +@_cdecl("bjs_throwsStringError") +public func _bjs_throwsStringError() -> Void { + #if arch(wasm32) + do { + try throwsStringError() + } catch let error { + error.bridgeJSLowerThrow() + return + } + #else + fatalError("Only available on WebAssembly") + #endif +} + @_expose(wasm, "bjs_throwsSwiftError") @_cdecl("bjs_throwsSwiftError") public func _bjs_throwsSwiftError(_ shouldThrow: Int32) -> Void { @@ -5440,16 +5446,7 @@ public func _bjs_throwsSwiftError(_ shouldThrow: Int32) -> Void { do { try throwsSwiftError(shouldThrow: Bool.bridgeJSLiftParameter(shouldThrow)) } 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)) - } - } + error.bridgeJSLowerThrow() return } #else @@ -5465,16 +5462,7 @@ public func _bjs_throwsWithIntResult() -> Int32 { let ret = try throwsWithIntResult() 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else @@ -5490,16 +5478,7 @@ public func _bjs_throwsWithStringResult() -> Void { let ret = try throwsWithStringResult() 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)) - } - } + error.bridgeJSLowerThrow() return } #else @@ -5515,16 +5494,7 @@ public func _bjs_throwsWithBoolResult() -> Int32 { let ret = try throwsWithBoolResult() 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else @@ -5540,16 +5510,7 @@ public func _bjs_throwsWithFloatResult() -> Float32 { let ret = try throwsWithFloatResult() 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)) - } - } + error.bridgeJSLowerThrow() return 0.0 } #else @@ -5565,16 +5526,7 @@ public func _bjs_throwsWithDoubleResult() -> Float64 { let ret = try throwsWithDoubleResult() 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)) - } - } + error.bridgeJSLowerThrow() return 0.0 } #else @@ -5590,16 +5542,7 @@ public func _bjs_throwsWithSwiftHeapObjectResult() -> UnsafeMutableRawPointer { let ret = try throwsWithSwiftHeapObjectResult() 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)) - } - } + error.bridgeJSLowerThrow() return UnsafeMutableRawPointer(bitPattern: -1).unsafelyUnwrapped } #else @@ -5615,16 +5558,7 @@ public func _bjs_throwsWithJSObjectResult() -> Int32 { let ret = try throwsWithJSObjectResult() 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)) - } - } + error.bridgeJSLowerThrow() return 0 } #else diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 746e0cd3c..74e598606 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -10066,6 +10066,23 @@ } } }, + { + "abiName" : "bjs_throwsStringError", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "throwsStringError", + "parameters" : [ + + ], + "returnType" : { + "void" : { + + } + } + }, { "abiName" : "bjs_throwsSwiftError", "effects" : { diff --git a/Tests/prelude.mjs b/Tests/prelude.mjs index 4c211e91c..1008c9dad 100644 --- a/Tests/prelude.mjs +++ b/Tests/prelude.mjs @@ -512,6 +512,15 @@ function BridgeJSRuntimeTests_runJsWorks(instance, exports) { assert.fail("Expected no error"); } + // Test ConvertibleToJSException with custom error type + try { + exports.throwsStringError(); + assert.fail("Expected error"); + } catch (error) { + assert.ok(error instanceof Error, "Custom error should be an Error instance"); + assert.equal(error.message, "Custom string error"); + } + // Test enums assert.equal(exports.Direction.North, 0); assert.equal(exports.Direction.South, 1); From 3649f2e70d0e0c46e050e6a5850d67f0f640feb6 Mon Sep 17 00:00:00 2001 From: William Taylor Date: Wed, 11 Mar 2026 10:55:43 +1100 Subject: [PATCH 2/2] Fix formatting --- .../Sources/BridgeJSCore/SwiftToSkeleton.swift | 3 ++- Sources/JavaScriptKit/BridgeJSIntrinsics.swift | 12 ++++++++++-- Sources/JavaScriptKit/ConvertibleToJSException.swift | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 745d50cb6..a651ea78e 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -1126,7 +1126,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor { guard throwsClause.type != nil else { diagnose( node: throwsClause, - message: "Thrown type must be specified. Only JSException or any error conforming to ConvertibleToJSException is supported" + message: + "Thrown type must be specified. Only JSException or any error conforming to ConvertibleToJSException is supported" ) return nil } diff --git a/Sources/JavaScriptKit/BridgeJSIntrinsics.swift b/Sources/JavaScriptKit/BridgeJSIntrinsics.swift index 412b05825..238cddfbb 100644 --- a/Sources/JavaScriptKit/BridgeJSIntrinsics.swift +++ b/Sources/JavaScriptKit/BridgeJSIntrinsics.swift @@ -54,12 +54,20 @@ extension ConvertibleToJSException { } extension Error { - @available(*, unavailable, message: "It is only possible to throw a JSException or an Error which conforms to ConvertibleToJSException") + @available( + *, + unavailable, + message: "It is only possible to throw a JSException or an Error which conforms to ConvertibleToJSException" + ) @_spi(BridgeJS) public func bridgeJSLowerThrow() { fatalError() } - @available(*, unavailable, message: "It is only possible to throw a JSException or an Error which conforms to ConvertibleToJSException") + @available( + *, + unavailable, + message: "It is only possible to throw a JSException or an Error which conforms to ConvertibleToJSException" + ) @_spi(BridgeJS) public func bridgeJSLowerThrowAsync() -> JSException { fatalError() } diff --git a/Sources/JavaScriptKit/ConvertibleToJSException.swift b/Sources/JavaScriptKit/ConvertibleToJSException.swift index 3f98743d1..ef3f446aa 100644 --- a/Sources/JavaScriptKit/ConvertibleToJSException.swift +++ b/Sources/JavaScriptKit/ConvertibleToJSException.swift @@ -1,5 +1,5 @@ /// Errors that can be converted to a JSException. -/// +/// /// If an error conforms to this protocol, it is possible to throw it from a `@JS` method. public protocol ConvertibleToJSException: Error { var jsException: JSException { get }