diff --git a/Package.swift b/Package.swift index 3d0f1e943..63eacf6be 100644 --- a/Package.swift +++ b/Package.swift @@ -198,6 +198,7 @@ let package = Package( "bridge-js.global.d.ts", "Generated/JavaScript", "JavaScript", + "Modules", ], swiftSettings: [ .enableExperimentalFeature("Extern") diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 5b5155fdc..2d2a3ab6f 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -23,6 +23,8 @@ public final class SwiftToSkeleton { private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = [] private var usedExternalModules = Set() + private let javaScriptModuleExists: (String) throws -> Bool + private var validatedJavaScriptModulePaths = Set() /// Non-fatal diagnostics collected during `finalize()`. These do not fail the build. public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = [] @@ -32,12 +34,14 @@ public final class SwiftToSkeleton { moduleName: String, exposeToGlobal: Bool, externalModuleIndex: ExternalModuleIndex, - identityMode: String? = nil + identityMode: String? = nil, + javaScriptModuleExists: @escaping (String) throws -> Bool = { _ in false } ) { self.progress = progress self.moduleName = moduleName self.exposeToGlobal = exposeToGlobal self.identityMode = identityMode + self.javaScriptModuleExists = javaScriptModuleExists self.typeDeclResolver = TypeDeclResolver() self.externalModuleIndex = externalModuleIndex @@ -90,6 +94,57 @@ public final class SwiftToSkeleton { ) importCollector.walk(sourceFile) + let importOrigins = + importCollector.importedFunctions.compactMap(\.from) + + importCollector.importedTypes.compactMap(\.from) + + importCollector.importedGlobalGetters.compactMap(\.from) + let modulePaths = Set(importOrigins.compactMap(\.modulePath)) + for path in modulePaths.sorted() { + if validatedJavaScriptModulePaths.contains(path) { + continue + } + let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile) + guard path.hasPrefix("/") else { + importCollector.errors.append( + DiagnosticError( + node: pathNode, + message: "JavaScript module paths must start with '/' to indicate the Swift target root: " + + "'\(path)'." + ) + ) + continue + } + guard !path.split(separator: "/").contains("..") else { + importCollector.errors.append( + DiagnosticError( + node: pathNode, + message: "JavaScript module paths must not contain '..': '\(path)'." + ) + ) + continue + } + let lowercasedPath = path.lowercased() + guard lowercasedPath.hasSuffix(".js") || lowercasedPath.hasSuffix(".mjs") else { + importCollector.errors.append( + DiagnosticError( + node: pathNode, + message: "JavaScript modules must use a '.js' or '.mjs' extension: '\(path)'." + ) + ) + continue + } + guard try javaScriptModuleExists(path) else { + importCollector.errors.append( + DiagnosticError( + node: pathNode, + message: "JavaScript module file was not found at '\(path)'." + ) + ) + continue + } + validatedJavaScriptModulePaths.insert(path) + } + let exportErrors = exportCollector.errors.filter { $0.severity == .error } let importErrorsFatal = importCollector.errors.filter { $0.severity == .error && !$0.message.contains("Unsupported type '") @@ -2413,6 +2468,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { var importedFunctions: [ImportedFunctionSkeleton] = [] var importedTypes: [ImportedTypeSkeleton] = [] var importedGlobalGetters: [ImportedGetterSkeleton] = [] + var importedModulePathNodes: [String: Syntax] = [:] var errors: [DiagnosticError] = [] private let inputFilePath: String @@ -2507,22 +2563,41 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } return nil } + } + + private func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? { + guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { $0.label?.text == "from" }) + else { + return nil + } - /// Extracts the `from` argument value from an attribute, if present. - static func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? { - guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else { + if let call = argument.expression.as(FunctionCallExprSyntax.self), + call.calledExpression.trimmedDescription.split(separator: ".").last == "module" + { + guard call.arguments.count == 1, + let pathExpression = call.arguments.first?.expression, + let literal = pathExpression.as(StringLiteralExprSyntax.self), + let path = literal.representedLiteralValue + else { + errors.append( + DiagnosticError( + node: call.arguments.first?.expression ?? argument.expression, + message: "JavaScript module path must be a string literal." + ) + ) return nil } - for argument in arguments { - guard argument.label?.text == "from" else { continue } - - // Accept `.global`, `JSImportFrom.global`, etc. - let description = argument.expression.trimmedDescription - let caseName = description.split(separator: ".").last.map(String.init) ?? description - return JSImportFrom(rawValue: caseName) + if importedModulePathNodes[path] == nil { + importedModulePathNodes[path] = Syntax(literal) } - return nil + return .module(path) } + + // Accept `.global`, `JSImportFrom.global`, etc. + let description = argument.expression.trimmedDescription + let caseName = description.split(separator: ".").last.map(String.init) ?? description + return caseName == "global" ? .global : nil } // MARK: - Validation Helpers @@ -2705,7 +2780,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) let jsName = attribute.flatMap(AttributeChecker.extractJSName) - let from = attribute.flatMap(AttributeChecker.extractJSImportFrom) + let from = attribute.flatMap { extractJSImportFrom(from: $0) } let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) } @@ -2722,7 +2797,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) let jsName = attribute.flatMap(AttributeChecker.extractJSName) - let from = attribute.flatMap(AttributeChecker.extractJSImportFrom) + let from = attribute.flatMap { extractJSImportFrom(from: $0) } let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) } @@ -2916,7 +2991,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text) let jsName = AttributeChecker.extractJSName(from: jsFunction) - let from = AttributeChecker.extractJSImportFrom(from: jsFunction) + let from = extractJSImportFrom(from: jsFunction) let name = baseName let parameters = parseParameters(from: node.signature.parameterClause) @@ -2969,7 +3044,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text) let jsName = AttributeChecker.extractJSName(from: jsGetter) - let from = AttributeChecker.extractJSImportFrom(from: jsGetter) + let from = extractJSImportFrom(from: jsGetter) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) return ImportedGetterSkeleton( name: propertyName, diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index 4706b14a4..d8a1ac780 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -16,6 +16,7 @@ public struct BridgeJSLink { let enableLifetimeTracking: Bool = false private let namespaceBuilder = NamespaceBuilder() private let intrinsicRegistry = JSIntrinsicRegistry() + private let importedModuleRegistry = ImportedJSModuleRegistry() public init( skeletons: [BridgeJSSkeleton] = [], @@ -40,10 +41,12 @@ public struct BridgeJSLink { return configIdentityMode == "pointer" } - mutating func addSkeletonFile(data: Data) throws { + @discardableResult + mutating func addSkeletonFile(data: Data) throws -> BridgeJSSkeleton { do { let unified = try JSONDecoder().decode(BridgeJSSkeleton.self, from: data) skeletons.append(unified) + return unified } catch { struct SkeletonDecodingError: Error, CustomStringConvertible { let description: String @@ -1077,6 +1080,11 @@ public struct BridgeJSLink { let printer = CodeFragmentPrinter(header: header) printer.nextLine() + printer.write(lines: importedModuleRegistry.importLines) + if !importedModuleRegistry.importLines.isEmpty { + printer.nextLine() + } + printer.write(lines: data.topLevelTypeLines) let exportedSkeletons = skeletons.compactMap(\.exported) @@ -1222,6 +1230,7 @@ public struct BridgeJSLink { public func link() throws -> (outputJs: String, outputDts: String) { intrinsicRegistry.reset() + importedModuleRegistry.configure(skeletons: skeletons) intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in guard let skeleton = unified.exported else { return } for klass in skeleton.classes { @@ -1586,7 +1595,7 @@ public struct BridgeJSLink { return "\"\(Self.escapeForJavaScriptStringLiteral(name))\"" } - fileprivate static func escapeForJavaScriptStringLiteral(_ string: String) -> String { + static func escapeForJavaScriptStringLiteral(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") @@ -3460,7 +3469,10 @@ extension BridgeJSLink { try thunkBuilder.liftParameter(param: param) } let jsName = function.jsName ?? function.name - let importRootExpr = function.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: importObjectBuilder.moduleName, + from: function.from + ) try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr) let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil)) @@ -3484,7 +3496,10 @@ extension BridgeJSLink { intrinsicRegistry: intrinsicRegistry ) let jsName = getter.jsName ?? getter.name - let importRootExpr = getter.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: importObjectBuilder.moduleName, + from: getter.from + ) try thunkBuilder.getImportProperty( name: jsName, fromObjectExpr: importRootExpr, @@ -3539,7 +3554,11 @@ extension BridgeJSLink { } for method in type.staticMethods { let abiName = method.abiName(context: type, operation: "static") - let (js, dts) = try renderImportedStaticMethod(context: type, method: method) + let (js, dts) = try renderImportedStaticMethod( + swiftModuleName: importObjectBuilder.moduleName, + context: type, + method: method + ) importObjectBuilder.assignToImportObject(name: abiName, function: js) importObjectBuilder.appendDts(dts) } @@ -3583,7 +3602,10 @@ extension BridgeJSLink { for param in constructor.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = type.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: importObjectBuilder.moduleName, + from: type.from + ) try thunkBuilder.callConstructor( jsName: type.jsName ?? type.name, swiftTypeName: type.name, @@ -3627,6 +3649,7 @@ extension BridgeJSLink { } func renderImportedStaticMethod( + swiftModuleName: String, context: ImportedTypeSkeleton, method: ImportedFunctionSkeleton ) throws -> (js: [String], dts: [String]) { @@ -3638,7 +3661,10 @@ extension BridgeJSLink { for param in method.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = context.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: swiftModuleName, + from: context.from + ) let constructorExpr = ImportedThunkBuilder.propertyAccessExpr( objectExpr: importRootExpr, propertyName: context.jsName ?? context.name diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift new file mode 100644 index 000000000..2c5716030 --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -0,0 +1,67 @@ +#if canImport(BridgeJSSkeleton) +import BridgeJSSkeleton +#endif + +final class ImportedJSModuleRegistry { + struct Reference: Hashable { + let swiftModuleName: String + let path: String + + var relativeOutputPath: String { + "bridge-js-modules/\(swiftModuleName)\(path)" + } + } + + private var aliases: [Reference: String] = [:] + private(set) var references: [Reference] = [] + + func configure(skeletons: [BridgeJSSkeleton]) { + aliases.removeAll(keepingCapacity: true) + references = Self.collectReferences(skeletons: skeletons) + for (index, reference) in references.enumerated() { + aliases[reference] = "__bjs_imported_module_\(index)" + } + } + + static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] { + var references = Set() + for skeleton in skeletons { + for file in skeleton.imported?.children ?? [] { + let origins = + file.functions.compactMap(\.from) + + file.globalGetters.compactMap(\.from) + + file.types.compactMap(\.from) + for case .module(let path) in origins { + references.insert(Reference(swiftModuleName: skeleton.moduleName, path: path)) + } + } + } + return references.sorted { + ($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path) + } + } + + func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String { + switch from { + case nil: + return "imports" + case .global: + return "globalThis" + case .module(let path): + let reference = Reference(swiftModuleName: swiftModuleName, path: path) + guard let alias = aliases[reference] else { + throw BridgeJSLinkError( + message: "Missing JavaScript module \(swiftModuleName)\(path)" + ) + } + return alias + } + } + + var importLines: [String] { + references.enumerated().map { index, reference in + let path = BridgeJSLink.escapeForJavaScriptStringLiteral(reference.relativeOutputPath) + return "import * as __bjs_imported_module_\(index) from \"./\(path)\";" + } + } +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index 7f45b6c39..c70ccdd8b 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -1101,7 +1101,7 @@ public struct ExportedSkeleton: Codable { } private var asyncClosureResolveReturnTypes: [BridgeType] { - var collector = AsyncClosureReturnTypeCollector() + let collector = AsyncClosureReturnTypeCollector() var walker = BridgeSkeletonWalker(visitor: collector) walker.walk(self) return walker.visitor.returnTypes @@ -1126,8 +1126,35 @@ private struct AsyncClosureReturnTypeCollector: BridgeSkeletonVisitor { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -public enum JSImportFrom: String, Codable { +/// - `module`: Read from a target-local ECMAScript module. +public enum JSImportFrom: Codable, Equatable, Sendable { case global + case module(String) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if value == "global" { + self = .global + } else if value.hasPrefix("/") && !value.split(separator: "/").contains("..") { + self = .module(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown import origin '\(value)'. Expected \"global\" or a rooted module path." + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(modulePath ?? "global") + } + + public var modulePath: String? { + guard case .module(let path) = self else { return nil } + return path + } } public struct ImportedFunctionSkeleton: Codable { diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index fa8a0a273..140ebda63 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -176,7 +176,13 @@ import BridgeJSUtilities moduleName: moduleName, exposeToGlobal: config.exposeToGlobal, externalModuleIndex: externalModuleIndex, - identityMode: config.identityMode + identityMode: config.identityMode, + javaScriptModuleExists: { path in + guard let file = JavaScriptModulePath.resolve(path, relativeTo: targetDirectory) else { + return false + } + return JavaScriptModulePath.isRegularFile(at: file) + } ) for inputFile in inputFiles.sorted() { try withSpan("Parsing \(inputFile)") { @@ -396,7 +402,7 @@ private func inputSwiftFiles(targetDirectory: URL, positionalArguments: [String] if positionalArguments.isEmpty { return recursivelyCollectSwiftFiles(from: targetDirectory).map(\.path) } - return positionalArguments + return positionalArguments.filter { URL(fileURLWithPath: $0).pathExtension == "swift" } } extension Profiling { diff --git a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift index 4a58f1972..cb6a5481c 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift @@ -122,8 +122,7 @@ import ArgumentParser var skeletonFiles: [String] func run() throws { - let (outputJs, _) = try linkSkeletons(skeletonFiles: skeletonFiles) - print(outputJs) + print(try linkSkeletons(skeletonFiles: skeletonFiles).outputJs) } } @@ -136,8 +135,7 @@ import ArgumentParser var skeletonFiles: [String] func run() throws { - let (_, outputDts) = try linkSkeletons(skeletonFiles: skeletonFiles) - print(outputDts) + print(try linkSkeletons(skeletonFiles: skeletonFiles).outputDts) } } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSUtilities/JavaScriptModulePath.swift b/Plugins/BridgeJS/Sources/BridgeJSUtilities/JavaScriptModulePath.swift new file mode 100644 index 000000000..8067f7233 --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSUtilities/JavaScriptModulePath.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum JavaScriptModulePath { + public static func resolve(_ path: String, relativeTo targetDirectory: URL) -> URL? { + let lowercasedPath = path.lowercased() + guard path.hasPrefix("/"), + !path.split(separator: "/").contains(".."), + lowercasedPath.hasSuffix(".js") || lowercasedPath.hasSuffix(".mjs") + else { + return nil + } + + let targetRoot = targetDirectory.standardizedFileURL + let file = URL(fileURLWithPath: targetRoot.path + path).standardizedFileURL + let targetPrefix = targetRoot.path.hasSuffix("/") ? targetRoot.path : targetRoot.path + "/" + guard file.path.hasPrefix(targetPrefix) else { + return nil + } + return file + } + + public static func isRegularFile(at url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 8b8e8b8a2..263d796a6 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -4,6 +4,7 @@ import SwiftParser import Testing @testable import BridgeJSCore +@testable import BridgeJSLink @testable import BridgeJSSkeleton @Suite struct BridgeJSCodegenTests { @@ -13,6 +14,44 @@ import Testing static let multifileInputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .appendingPathComponent("Inputs").appendingPathComponent("MacroSwift").appendingPathComponent("Multifile") + @Test + func javaScriptModuleReferencesAreStoredWithoutSourceContents() throws { + let modulePath = "/Modules/math.mjs" + let swiftSource = """ + @JSFunction(from: .module("/Modules/math.mjs")) + func add(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int + + @JSGetter(jsName: "version", from: .module("/Modules/math.mjs")) + var moduleVersion: String + """ + var validationCount = 0 + let generator = SwiftToSkeleton( + progress: .silent, + moduleName: "TestModule", + exposeToGlobal: false, + externalModuleIndex: .empty, + javaScriptModuleExists: { + validationCount += 1 + return $0 == modulePath + } + ) + generator.addSourceFile(Parser.parse(source: swiftSource), inputFilePath: "Imports.swift") + let skeleton = try generator.finalize() + let encoded = String(decoding: try JSONEncoder().encode(skeleton), as: UTF8.self) + let imported = try #require(skeleton.imported) + + #expect(validationCount == 1) + #expect(imported.children.flatMap(\.functions).first?.from == .module(modulePath)) + #expect(!encoded.contains(#""modules""#)) + } + + @Test + func invalidJSImportFromValueFailsToDecode() { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSImportFrom.self, from: Data(#""module.js""#.utf8)) + } + } + private func snapshotCodegen( skeleton: BridgeJSSkeleton, name: String, @@ -77,11 +116,19 @@ import Testing let url = Self.inputsDirectory.appendingPathComponent(input) let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) + let modulePaths: Set = + input == "JSImportModule.swift" + ? [ + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", + ] + : [] let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, - externalModuleIndex: .empty + externalModuleIndex: .empty, + javaScriptModuleExists: { modulePaths.contains($0) } ) swiftAPI.addSourceFile(sourceFile, inputFilePath: input) let skeleton = try swiftAPI.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift index 2f3f46fdb..0445fe5e1 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -14,13 +14,13 @@ import Testing function: String = #function, sourceLocation: Testing.SourceLocation = #_sourceLocation ) throws { - let (outputJs, outputDts) = try bridgeJSLink.link() + let output = try bridgeJSLink.link() try assertSnapshot( name: name, filePath: filePath, function: function, sourceLocation: sourceLocation, - input: outputJs.data(using: .utf8)!, + input: output.outputJs.data(using: .utf8)!, fileExtension: "js" ) try assertSnapshot( @@ -28,7 +28,7 @@ import Testing filePath: filePath, function: function, sourceLocation: sourceLocation, - input: outputDts.data(using: .utf8)!, + input: output.outputDts.data(using: .utf8)!, fileExtension: "d.ts" ) } @@ -49,11 +49,19 @@ import Testing let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) + let modulePaths: Set = + input == "JSImportModule.swift" + ? [ + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", + ] + : [] let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, - externalModuleIndex: .empty + externalModuleIndex: .empty, + javaScriptModuleExists: { modulePaths.contains($0) } ) importSwift.addSourceFile(sourceFile, inputFilePath: "\(name).swift") let importResult = try importSwift.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index 79ea47ebd..e8cf963e3 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftParser import SwiftSyntax import Testing @@ -6,6 +7,80 @@ import Testing @testable import BridgeJSSkeleton @Suite struct DiagnosticsTests { + private func moduleDiagnostics(source: String) -> BridgeJSCoreDiagnosticError? { + let swiftAPI = SwiftToSkeleton( + progress: .silent, + moduleName: "TestModule", + exposeToGlobal: false, + externalModuleIndex: .empty + ) + swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift") + do { + _ = try swiftAPI.finalize() + return nil + } catch let error as BridgeJSCoreDiagnosticError { + return error + } catch { + Issue.record("Unexpected error: \(error)") + return nil + } + } + + @Test + func missingJavaScriptModuleProducesDiagnostic() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("/missing.js")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module file was not found at '/missing.js'")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + + @Test + func javaScriptModulePathMustStartAtTargetRoot() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("missing.js")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module paths must start with '/'")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + + @Test + func javaScriptModulePathMustNotTraverse() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("/../missing.js")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module paths must not contain '..'")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + + @Test + func javaScriptModulePathMustUseSupportedExtension() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("/module.ts")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript modules must use a '.js' or '.mjs' extension")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + + @Test + func javaScriptModulePathMustBeStringLiteral() throws { + let source = """ + let modulePath = "/module.js" + @JSFunction(from: .module(modulePath)) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module path must be a string literal.")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + /// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics. private func firstParameterTypeNode(source: String) -> TypeSyntax? { let tree = Parser.parse(source: source) diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift new file mode 100644 index 000000000..3fd9d79e7 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift @@ -0,0 +1,17 @@ +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int + +@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) +func moduleRenamed() throws(JSException) -> String + +@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) +var moduleVersion: String + +@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +struct ModuleCounter { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ModuleCounter + @JSFunction func increment() throws(JSException) -> Int + @JSGetter var value: Int + @JSSetter func setValue(_ value: Int) throws(JSException) +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs new file mode 100644 index 000000000..3107e222f --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs @@ -0,0 +1,9 @@ +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "renamed"; +} + +export const version = "1.0"; diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs new file mode 100644 index 000000000..5a33b7ffa --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs @@ -0,0 +1,17 @@ +export class ModuleCounter { + constructor(value) { + this.value = value; + } + + static create(value) { + return new ModuleCounter(value); + } + + increment() { + return ++this.value; + } + + setValue(value) { + this.value = value; + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json new file mode 100644 index 000000000..d90e88f1d --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json @@ -0,0 +1,191 @@ +{ + "imported" : { + "children" : [ + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "name" : "moduleAdd", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "name" : "rhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "renamedFunction", + "name" : "moduleRenamed", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "version", + "name" : "moduleVersion", + "type" : { + "string" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ] + }, + "from" : "\/Modules\/ModuleCounter.mjs", + "getters" : [ + { + "accessLevel" : "internal", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "increment", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ModuleCounter", + "setters" : [ + { + "accessLevel" : "internal", + "functionName" : "value_set", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "staticMethods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "create", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "jsObject" : { + "_0" : "ModuleCounter" + } + } + } + ] + } + ] + } + ] + }, + "moduleName" : "TestModule", + "usedExternalModules" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift new file mode 100644 index 000000000..dc22aceb9 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift @@ -0,0 +1,166 @@ +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_moduleVersion_get") +fileprivate func bjs_moduleVersion_get_extern() -> Int32 +#else +fileprivate func bjs_moduleVersion_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleVersion_get() -> Int32 { + return bjs_moduleVersion_get_extern() +} + +func _$moduleVersion_get() throws(JSException) -> String { + let ret = bjs_moduleVersion_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_moduleAdd") +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 +#else +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleAdd(_ lhs: Int32, _ rhs: Int32) -> Int32 { + return bjs_moduleAdd_extern(lhs, rhs) +} + +func _$moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int { + let rhsValue = rhs.bridgeJSLowerParameter() + let lhsValue = lhs.bridgeJSLowerParameter() + let ret = bjs_moduleAdd(lhsValue, rhsValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_moduleRenamed") +fileprivate func bjs_moduleRenamed_extern() -> Int32 +#else +fileprivate func bjs_moduleRenamed_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleRenamed() -> Int32 { + return bjs_moduleRenamed_extern() +} + +func _$moduleRenamed() throws(JSException) -> String { + let ret = bjs_moduleRenamed() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_init") +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_init(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_create_static") +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_create_static(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_value_get") +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_get(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_value_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_value_set") +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +#else +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { + return bjs_ModuleCounter_value_set_extern(self, newValue) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_increment") +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_increment_extern(self) +} + +func _$ModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_create(_ value: Int) throws(JSException) -> ModuleCounter { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ModuleCounter.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { + let newValueValue = newValue.bridgeJSLowerParameter() + let selfValue = self.bridgeJSLowerParameter() + bjs_ModuleCounter_value_set(selfValue, newValueValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +func _$ModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_increment(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts new file mode 100644 index 000000000..624691d83 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts @@ -0,0 +1,21 @@ +// 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 ModuleCounter { + increment(): number; + value: number; +} +export type Exports = { +} +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/JSImportModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js new file mode 100644 index 000000000..cb4767f03 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js @@ -0,0 +1,299 @@ +// 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`. + +import * as __bjs_imported_module_0 from "./bridge-js-modules/TestModule/Modules/JSImportModule.mjs"; +import * as __bjs_imported_module_1 from "./bridge-js-modules/TestModule/Modules/ModuleCounter.mjs"; + +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 i64Stack = []; + let f32Stack = []; + let f64Stack = []; + let ptrStack = []; + let taStack = []; + 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 >>> 0); + 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 >>> 0, len >>> 0); + 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_push_i64"] = function(v) { + i64Stack.push(v); + } + bjs["swift_js_pop_i64"] = function() { + return i64Stack.pop(); + } + const taCtors = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + bjs["swift_js_push_typed_array"] = function(kind, ptr, count) { + const Ctor = taCtors[kind]; + const byteLen = count * Ctor.BYTES_PER_ELEMENT; + const copy = memory.buffer.slice(ptr, ptr + byteLen); + taStack.push(Array.from(new Ctor(copy))); + } + const __bjs_promiseSettlers = Symbol("JavaScriptKit.promiseSettlers"); + bjs["swift_js_make_promise"] = function() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + promise[__bjs_promiseSettlers] = { resolve, reject }; + return swift.memory.retain(promise); + } + 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) {} + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_moduleVersion_get"] = function bjs_moduleVersion_get() { + try { + let ret = __bjs_imported_module_0.version; + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_moduleAdd"] = function bjs_moduleAdd(lhs, rhs) { + try { + let ret = __bjs_imported_module_0.moduleAdd(lhs, rhs); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_moduleRenamed"] = function bjs_moduleRenamed() { + try { + let ret = __bjs_imported_module_0.renamedFunction(); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_ModuleCounter_init"] = function bjs_ModuleCounter_init(value) { + try { + return swift.memory.retain(new __bjs_imported_module_1.ModuleCounter(value)); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ModuleCounter_value_get"] = function bjs_ModuleCounter_value_get(self) { + try { + let ret = swift.memory.getObject(self).value; + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ModuleCounter_value_set"] = function bjs_ModuleCounter_value_set(self, newValue) { + try { + swift.memory.getObject(self).value = newValue; + } catch (error) { + setException(error); + } + } + TestModule["bjs_ModuleCounter_create_static"] = function bjs_ModuleCounter_create_static(value) { + try { + let ret = __bjs_imported_module_1.ModuleCounter.create(value); + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ModuleCounter_increment"] = function bjs_ModuleCounter_increment(self) { + try { + let ret = swift.memory.getObject(self).increment(); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + }, + 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 = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index ff3e2ce5a..d86a34fa1 100644 --- a/Plugins/PackageToJS/Sources/PackageToJS.swift +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -273,6 +273,7 @@ struct PackageToJSError: Swift.Error, CustomStringConvertible { protocol PackagingSystem { func createDirectory(atPath: String) throws + func removeItemIfExists(atPath: String) throws func syncFile(from: String, to: String) throws func writeFile(atPath: String, content: Data) throws @@ -281,6 +282,12 @@ protocol PackagingSystem { } extension PackagingSystem { + func removeItemIfExists(atPath: String) throws { + if FileManager.default.fileExists(atPath: atPath) { + try FileManager.default.removeItem(atPath: atPath) + } + } + func createDirectory(atPath: String) throws { guard !FileManager.default.fileExists(atPath: atPath) else { return } try FileManager.default.createDirectory( @@ -387,8 +394,18 @@ private func runCommand(_ command: URL, _ arguments: [String]) throws { } } +struct BridgeJSSkeletonInput { + let source: URL + let targetDirectory: URL +} + /// Plans the build for packaging. struct PackagingPlanner { + struct JavaScriptModuleInput { + let source: BuildPath + let relativeOutputPath: String + } + /// The options for packaging let options: PackageToJS.PackageOptions /// The package ID of the package that this plugin is running on @@ -398,7 +415,7 @@ struct PackagingPlanner { /// The path of this file itself, used to capture changes of planner code let selfPath: BuildPath /// The BridgeJS API skeletons source files - let skeletons: [BuildPath] + let skeletons: [BridgeJSSkeletonInput] /// The directory for the final output let outputDir: BuildPath /// The directory for intermediate files @@ -419,7 +436,7 @@ struct PackagingPlanner { packageId: String, intermediatesDir: BuildPath, selfPackageDir: BuildPath, - skeletons: [BuildPath], + skeletons: [BridgeJSSkeletonInput], outputDir: BuildPath, wasmProductArtifact: BuildPath, wasmFilename: String, @@ -594,24 +611,49 @@ struct PackagingPlanner { ) packageInputs.append(packageJsonTask) - if skeletons.count > 0 { + if !skeletons.isEmpty { + let bridge = try loadBridgeJS() + let skeletonFiles = skeletons.map { BuildPath(absolute: $0.source.path) } let bridgeJs = outputDir.appending(path: "bridge-js.js") let bridgeDts = outputDir.appending(path: "bridge-js.d.ts") + let bridgeModules = outputDir.appending(path: "bridge-js-modules") packageInputs.append( - make.addTask(inputFiles: skeletons + [selfPath], output: bridgeJs) { _, scope in - var link = BridgeJSLink( - sharedMemory: Self.isSharedMemoryEnabled(triple: triple) + make.addTask(inputFiles: skeletonFiles + [selfPath], output: bridgeJs) { _, scope in + let output = try bridge.link.link() + try system.writeFile( + atPath: scope.resolve(path: bridgeJs).path, + content: Data(output.outputJs.utf8) ) - - // Decode skeleton format - for skeletonPath in skeletons { - let data = try Data(contentsOf: URL(fileURLWithPath: scope.resolve(path: skeletonPath).path)) - try link.addSkeletonFile(data: data) + try system.writeFile( + atPath: scope.resolve(path: bridgeDts).path, + content: Data(output.outputDts.utf8) + ) + } + ) + let bridgeModulesStamp = intermediatesDir.appending(path: "bridge-js-modules.stamp") + packageInputs.append( + make.addTask( + inputFiles: skeletonFiles + bridge.modules.map(\.source) + [selfPath], + inputTasks: [outputDirTask, intermediatesDirTask], + output: bridgeModulesStamp + ) { _, scope in + let modulesDirectory = scope.resolve(path: bridgeModules) + try system.removeItemIfExists(atPath: modulesDirectory.path) + if !bridge.modules.isEmpty { + try system.createDirectory(atPath: modulesDirectory.path) } - - let (outputJs, outputDts) = try link.link() - try system.writeFile(atPath: scope.resolve(path: bridgeJs).path, content: Data(outputJs.utf8)) - try system.writeFile(atPath: scope.resolve(path: bridgeDts).path, content: Data(outputDts.utf8)) + for module in bridge.modules { + let destination = scope.resolve(path: outputDir.appending(path: module.relativeOutputPath)) + try system.createDirectory(atPath: destination.deletingLastPathComponent().path) + try system.syncFile( + from: scope.resolve(path: module.source).path, + to: destination.path + ) + } + try system.writeFile( + atPath: scope.resolve(path: bridgeModulesStamp).path, + content: Data() + ) } ) } @@ -645,6 +687,47 @@ struct PackagingPlanner { return (packageInputs, outputDirTask, intermediatesDirTask, packageJsonTask) } + private func loadBridgeJS() throws -> ( + link: BridgeJSLink, + modules: [JavaScriptModuleInput] + ) { + var link = BridgeJSLink( + sharedMemory: Self.isSharedMemoryEnabled(triple: triple) + ) + var moduleSources: [String: BuildPath] = [:] + + for input in skeletons { + let skeleton = try link.addSkeletonFile(data: Data(contentsOf: input.source)) + for reference in ImportedJSModuleRegistry.collectReferences(skeletons: [skeleton]) { + guard + let sourceURL = JavaScriptModulePath.resolve( + reference.path, + relativeTo: input.targetDirectory + ), + JavaScriptModulePath.isRegularFile(at: sourceURL) + else { + throw PackageToJSError( + "JavaScript module file was not found at '\(reference.path)' in target '\(skeleton.moduleName)'." + ) + } + let source = BuildPath(absolute: sourceURL.path) + if let existing = moduleSources[reference.relativeOutputPath], existing != source { + throw PackageToJSError( + "Conflicting JavaScript module sources for '\(reference.relativeOutputPath)'." + ) + } + moduleSources[reference.relativeOutputPath] = source + } + } + + return ( + link, + moduleSources.sorted { $0.key < $1.key }.map { + JavaScriptModuleInput(source: $0.value, relativeOutputPath: $0.key) + } + ) + } + /// Construct the test build plan and return the root task key func planTestBuild( make: inout MiniMake diff --git a/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift b/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift index 7686372f9..cc16de20a 100644 --- a/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift +++ b/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift @@ -704,7 +704,7 @@ class SkeletonCollector { private var visitedProducts: Set = [] private var visitedTargets: Set = [] - var skeletons: [URL] = [] + var skeletons: [BridgeJSSkeletonInput] = [] let skeletonFile = "BridgeJS.json" let context: PluginContext @@ -712,7 +712,7 @@ class SkeletonCollector { self.context = context } - func collectFromProduct(name: String) -> [URL] { + func collectFromProduct(name: String) -> [BridgeJSSkeletonInput] { guard let product = context.package.products.first(where: { $0.name == name }) else { return [] } @@ -720,7 +720,7 @@ class SkeletonCollector { return skeletons } - func collectFromTests() -> [URL] { + func collectFromTests() -> [BridgeJSSkeletonInput] { let tests = context.package.targets.filter { guard let target = $0 as? SwiftSourceModuleTarget else { return false } return target.kind == .test @@ -758,7 +758,12 @@ class SkeletonCollector { ] for skeletonURL in candidates { if FileManager.default.fileExists(atPath: skeletonURL.path) { - skeletons.append(skeletonURL) + skeletons.append( + BridgeJSSkeletonInput( + source: skeletonURL, + targetDirectory: target.directoryURL + ) + ) } } } @@ -788,7 +793,7 @@ extension PackagingPlanner { options: PackageToJS.PackageOptions, context: PluginContext, selfPackage: Package, - skeletons: [URL], + skeletons: [BridgeJSSkeletonInput], outputDir: URL, wasmProductArtifact: URL, wasmFilename: String @@ -803,7 +808,7 @@ extension PackagingPlanner { absolute: context.pluginWorkDirectoryURL.appending(path: outputBaseName + ".tmp").path ), selfPackageDir: BuildPath(absolute: selfPackage.directoryURL.path), - skeletons: skeletons.map { BuildPath(absolute: $0.path) }, + skeletons: skeletons, outputDir: BuildPath(absolute: outputDir.path), wasmProductArtifact: BuildPath(absolute: wasmProductArtifact.path), wasmFilename: wasmFilename, diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index daf4f3ab0..b0be54bb4 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -49,13 +49,15 @@ const decode = (kind, payload1, payload2, objectSpace) => { // Note: // `decodeValues` assumes that the size of RawJSValue is 16. const decodeArray = (ptr, length, memory, objectSpace) => { + const basePtr = ptr >>> 0; + const count = length >>> 0; // fast path for empty array - if (length === 0) { + if (count === 0) { return []; } let result = []; - for (let index = 0; index < length; index++) { - const base = ptr + 16 * index; + for (let index = 0; index < count; index++) { + const base = basePtr + 16 * index; const kind = memory.getUint32(base, true); const payload1 = memory.getUint32(base + 4, true); const payload2 = memory.getFloat64(base + 8, true); @@ -69,25 +71,27 @@ const decodeArray = (ptr, length, memory, objectSpace) => { // This function should be used only when kind flag is stored in memory. const write = (value, kind_ptr, payload1_ptr, payload2_ptr, is_exception, memory, objectSpace) => { const kind = writeAndReturnKindBits(value, payload1_ptr, payload2_ptr, is_exception, memory, objectSpace); - memory.setUint32(kind_ptr, kind, true); + memory.setUint32(kind_ptr >>> 0, kind, true); }; const writeAndReturnKindBits = (value, payload1_ptr, payload2_ptr, is_exception, memory, objectSpace) => { const exceptionBit = (is_exception ? 1 : 0) << 31; + const payload1Offset = payload1_ptr >>> 0; + const payload2Offset = payload2_ptr >>> 0; if (value === null) { return exceptionBit | 4 /* Kind.Null */; } const writeRef = (kind) => { - memory.setUint32(payload1_ptr, objectSpace.retain(value), true); + memory.setUint32(payload1Offset, objectSpace.retain(value), true); return exceptionBit | kind; }; const type = typeof value; switch (type) { case "boolean": { - memory.setUint32(payload1_ptr, value ? 1 : 0, true); + memory.setUint32(payload1Offset, value ? 1 : 0, true); return exceptionBit | 0 /* Kind.Boolean */; } case "number": { - memory.setFloat64(payload2_ptr, value, true); + memory.setFloat64(payload2Offset, value, true); return exceptionBit | 2 /* Kind.Number */; } case "string": { @@ -114,9 +118,11 @@ const writeAndReturnKindBits = (value, payload1_ptr, payload2_ptr, is_exception, throw new Error("Unreachable"); }; function decodeObjectRefs(ptr, length, memory) { - const result = new Array(length); - for (let i = 0; i < length; i++) { - result[i] = memory.getUint32(ptr + 4 * i, true); + const basePtr = ptr >>> 0; + const count = length >>> 0; + const result = new Array(count); + for (let i = 0; i < count; i++) { + result[i] = memory.getUint32(basePtr + 4 * i, true); } return result; } @@ -617,25 +623,29 @@ class SwiftRuntime { const memory = this.memory; const bytes = this.textEncoder.encode(memory.getObject(ref)); const bytes_ptr = memory.retain(bytes); - this.getDataView().setUint32(bytes_ptr_result, bytes_ptr, true); + this.getDataView().setUint32(bytes_ptr_result >>> 0, bytes_ptr, true); return bytes.length; }, swjs_decode_string: // NOTE: TextDecoder can't decode typed arrays backed by SharedArrayBuffer this.options.sharedMemory == true ? (bytes_ptr, length) => { - const bytes = this.getUint8Array().slice(bytes_ptr, bytes_ptr + length); + const bytesOffset = bytes_ptr >>> 0; + const byteLength = length >>> 0; + const bytes = this.getUint8Array().slice(bytesOffset, bytesOffset + byteLength); const string = this.textDecoder.decode(bytes); return this.memory.retain(string); } : (bytes_ptr, length) => { - const bytes = this.getUint8Array().subarray(bytes_ptr, bytes_ptr + length); + const bytesOffset = bytes_ptr >>> 0; + const byteLength = length >>> 0; + const bytes = this.getUint8Array().subarray(bytesOffset, bytesOffset + byteLength); const string = this.textDecoder.decode(bytes); return this.memory.retain(string); }, swjs_load_string: (ref, buffer) => { const bytes = this.memory.getObject(ref); - this.getUint8Array().set(bytes, buffer); + this.getUint8Array().set(bytes, buffer >>> 0); }, swjs_call_function: (ref, argv, argc, payload1_ptr, payload2_ptr) => { const memory = this.memory; @@ -739,7 +749,7 @@ class SwiftRuntime { // See https://github.com/swiftwasm/swift/issues/5599 return this.memory.retain(new ArrayType()); } - const array = new ArrayType(this.wasmMemory.buffer, elementsPtr, length); + const array = new ArrayType(this.wasmMemory.buffer, elementsPtr >>> 0, length >>> 0); // Call `.slice()` to copy the memory return this.memory.retain(array.slice()); }, @@ -750,7 +760,7 @@ class SwiftRuntime { const memory = this.memory; const typedArray = memory.getObject(ref); const bytes = new Uint8Array(typedArray.buffer); - this.getUint8Array().set(bytes, buffer); + this.getUint8Array().set(bytes, buffer >>> 0); }, swjs_release: (ref) => { this.memory.release(ref); diff --git a/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift b/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift index 3e0d67a3e..e5f04402a 100644 --- a/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift +++ b/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift @@ -9,10 +9,16 @@ import Testing } class TestPackagingSystem: PackagingSystem { var npmInstallCalls: [String] = [] + var writtenFiles: [String] = [] func npmInstall(packageDir: String) throws { npmInstallCalls.append(packageDir) } + func writeFile(atPath: String, content: Data) throws { + writtenFiles.append(atPath) + try content.write(to: URL(fileURLWithPath: atPath)) + } + func wasmOpt(_ arguments: [String], input: String, output: String) throws { try FileManager.default.copyItem( at: URL(fileURLWithPath: input), @@ -110,4 +116,96 @@ import Testing return root } } + + @Test func editingJavaScriptModuleOnlyResyncsModules() throws { + try withTemporaryDirectory { temporaryDirectory, _ in + let skeleton = temporaryDirectory.appending(path: "BridgeJS.json") + let module = temporaryDirectory.appending(path: "module.mjs") + let wasm = temporaryDirectory.appending(path: "main.wasm") + let plannerSource = temporaryDirectory.appending(path: "PackageToJS.swift") + let output = temporaryDirectory.appending(path: "output") + let intermediates = temporaryDirectory.appending(path: "intermediates") + + let bridgeSkeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [ + ImportedFileSkeleton( + functions: [ + ImportedFunctionSkeleton( + name: "value", + from: .module("/module.mjs"), + parameters: [], + returnType: .void + ) + ], + types: [] + ) + ] + ) + ) + try JSONEncoder().encode(bridgeSkeleton).write(to: skeleton) + try Data("export const value = 1;\n".utf8).write(to: module) + try Data([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).write(to: wasm) + try Data().write(to: plannerSource) + + let system = TestPackagingSystem() + let planner = PackagingPlanner( + options: PackageToJS.PackageOptions(), + packageId: "test", + intermediatesDir: BuildPath(absolute: intermediates.path), + selfPackageDir: BuildPath( + absolute: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + ), + skeletons: [ + .init( + source: skeleton, + targetDirectory: temporaryDirectory + ) + ], + outputDir: BuildPath(absolute: output.path), + wasmProductArtifact: BuildPath(absolute: wasm.path), + wasmFilename: "main.wasm", + configuration: "debug", + triple: "wasm32-unknown-wasi", + selfPath: BuildPath(absolute: plannerSource.path), + system: system + ) + var make = MiniMake(printProgress: { _, _ in }) + let root = try planner.planBuild( + make: &make, + buildOptions: PackageToJS.BuildOptions( + product: "test", + noOptimize: false, + debugInfoFormat: .none, + packageOptions: PackageToJS.PackageOptions() + ) + ) + let scope = MiniMake.VariableScope(variables: [:]) + + try make.build(output: root, scope: scope) + + let copiedModule = output.appending( + path: "bridge-js-modules/TestModule/module.mjs" + ) + #expect(try String(contentsOf: copiedModule, encoding: .utf8) == "export const value = 1;\n") + let initialLinkCount = system.writtenFiles.filter { $0.hasSuffix("/bridge-js.js") }.count + #expect(initialLinkCount == 1) + + try Data("export const value = 2;\n".utf8).write(to: module) + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(10)], + ofItemAtPath: module.path + ) + try make.build(output: root, scope: scope) + + #expect(try String(contentsOf: copiedModule, encoding: .utf8) == "export const value = 2;\n") + #expect(system.writtenFiles.filter { $0.hasSuffix("/bridge-js.js") }.count == initialLinkCount) + } + } } diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md index 14fccbfc1..6d06d4339 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md @@ -8,7 +8,7 @@ Learn how to make JavaScript APIs callable from your Swift code using macro-anno > Tip: You can quickly preview what interfaces will be exposed on the Swift/JavaScript/TypeScript sides using the [BridgeJS Playground](https://swiftwasm.org/JavaScriptKit/PlayBridgeJS/). -You can import JavaScript APIs into Swift in two ways: +You can define JavaScript bindings for Swift in two ways: 1. **Annotate Swift with macros** - Use `@JSFunction`, `@JSClass`, `@JSGetter`, and `@JSSetter` to declare bindings directly in Swift. No TypeScript required. Prefer this to get started. 2. **Generate bindings from TypeScript** - Use a `bridge-js.d.ts` file; the BridgeJS plugin generates the same macro-annotated Swift. See when you have existing `.d.ts` definitions or many APIs to bind. @@ -25,10 +25,13 @@ Add the BridgeJS plugin and enable the Extern feature as described in for an example. ```swift import JavaScriptKit @@ -95,4 +98,4 @@ exports.run(); - - -- \ No newline at end of file +- diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md index 4302e0e49..185b47d1f 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md @@ -24,6 +24,28 @@ import JavaScriptKit If the class is on `globalThis`, add `from: .global` to `@JSClass` and omit the type from `getImports()` in the next step. +For a class shipped as an ECMAScript module, put the origin on `@JSClass`: + +```javascript +// JavaScript/greeter.js +export class Greeter { + constructor(name) { this.name = name; } + static named(name) { return new Greeter(name); } + greet() { return `Hello, ${this.name}!`; } +} +``` + +```swift +@JSClass(from: .module("/JavaScript/greeter.js")) +struct Greeter { + @JSFunction init(_ name: String) throws(JSException) + @JSFunction static func named(_ name: String) throws(JSException) -> Greeter + @JSFunction func greet() throws(JSException) -> String +} +``` + +The path's leading `/` denotes the Swift target root, not the filesystem root. The module's named class export is the root for construction and static methods. Instance methods, getters, and setters operate on the wrapped object and must not specify their own `from:` argument. Use `jsName` on `@JSClass` to select a differently named class export. JavaScript inheritance may be implemented normally in the module; the Swift declaration describes the API visible on the exported class and its instances. + ### 2. Wire the JavaScript side **If you chose injection:** Implement the class in JavaScript and pass it in `getImports()`. @@ -47,7 +69,7 @@ const { exports } = await init({ }); ``` -**If you chose global:** Do not pass the class in `getImports()`; the runtime will resolve it from `globalThis`. +**If you chose global or module-backed lookup:** Do not pass the class in `getImports()`; the runtime resolves it from `globalThis` or the copied module. ## Macro options diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md index 64475acfa..c57aeda03 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md @@ -17,6 +17,30 @@ import JavaScriptKit To bind a function that lives on the JavaScript global object (e.g. `parseInt`, `setTimeout`), add `from: .global`. Use `jsName` when the Swift name differs from the JavaScript name - see the ``JSFunction(jsName:from:)`` API reference for options. +To ship the function with the Swift target, put it in a `.js` or `.mjs` ECMAScript module and use a target-rooted path: + +```javascript +// JavaScript/math.js +export function add(a, b) { return a + b; } +``` + +```swift +@JSFunction(from: .module("/JavaScript/math.js")) +func add(_ a: Double, _ b: Double) throws(JSException) -> Double +``` + +The leading `/` denotes the Swift target root, not the filesystem root. BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is copied and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. + +SwiftPM does not know what to do with `.js`/`.mjs` files inside a target, so exclude the directory holding them to avoid an "unhandled files" warning: + +```swift +.target( + name: "MyApp", + exclude: ["JavaScript"], + plugins: [.plugin(name: "BridgeJS", package: "JavaScriptKit")] +) +``` + ### 2. Provide the implementation at initialization Return the corresponding function(s) in the object passed to `getImports()` when initializing the WebAssembly module. @@ -35,7 +59,7 @@ const { exports } = await init({ }); ``` -If you used `from: .global`, do not pass the function in `getImports()`; the runtime resolves it from `globalThis`. +If you used `from: .global` or `.module`, do not pass the function in `getImports()`. Module-only bindings do not require `getImports()` at all. ### 3. Handle errors diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md index 044ebbe52..6825875dd 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md @@ -17,6 +17,20 @@ import JavaScriptKit To bind a variable that is not on `globalThis`, omit `from: .global` and supply the value in `getImports()` in the next step. Use `jsName` when the Swift name differs from the JavaScript property name - see the ``JSGetter(jsName:from:)`` API reference. +A top-level getter can also read a named export from a target-rooted module: + +```javascript +// JavaScript/config.js +export const environment = "production"; +``` + +```swift +@JSGetter(jsName: "environment", from: .module("/JavaScript/config.js")) +var currentEnvironment: String +``` + +The path's leading `/` denotes the Swift target root, not the filesystem root. Module exports are read-only through this API. Top-level `@JSSetter` remains unsupported. + ### 2. Add a setter for writable variables (optional) If the JavaScript property is writable and you need to set it from Swift, add a corresponding `@JSSetter` function. Property setters are exposed as functions (e.g. `setMyConfig(_:)`) because Swift property setters cannot `throw`. @@ -28,7 +42,7 @@ If the JavaScript property is writable and you need to set it from Swift, add a ### 3. Provide the value at initialization (injected only) -If you did **not** use `from: .global`, pass the value in the object returned by `getImports()` when initializing the WebAssembly module. +If you omitted `from`, pass the value in the object returned by `getImports()` when initializing the WebAssembly module. ```javascript // index.js @@ -43,13 +57,14 @@ const { exports } = await init({ }); ``` -If you used `from: .global`, do not pass the variable in `getImports()`; the runtime reads it from `globalThis`. +If you used `from: .global` or `.module`, do not pass the variable in `getImports()`; the runtime resolves it from `globalThis` or the copied module. ## Supported features | Feature | Status | |:--|:--| | Read-only global (e.g. `document`, `console`) | ✅ | +| Read-only module export | ✅ | | Writable global | ✅ (`@JSSetter`) | | Injected variable (via `getImports()`) | ✅ | diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md index 83213aca1..238bc8687 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md @@ -6,6 +6,14 @@ Limitations and unsupported patterns when using BridgeJS. BridgeJS generates glue code per Swift target (module). Some patterns that are valid in Swift or TypeScript are not supported across the bridge today. This article summarizes the main limitations so you can design your APIs accordingly. +## File-backed JavaScript modules + +Files referenced by `JSImportFrom.module` must be nonempty `.js` or `.mjs` paths beginning with `/`. This leading slash denotes the Swift target root, not the filesystem root. Files must remain within that Swift target. Only explicitly referenced files are copied. BridgeJS does not discover or rewrite an imported module's dependency graph, so referenced files should currently be self-contained. + +Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. + +Module origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member module overrides are not supported. + ## Type usage crossing module boundary ### Exporting Swift: extending types from another Swift module diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 191cf15d6..7a1bb4091 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -9,8 +9,11 @@ public enum JSEnumStyle: String { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -public enum JSImportFrom: String { +/// - `module`: Read a named export from an ECMAScript module file rooted at the Swift target. +public enum JSImportFrom { case global + /// Read from an ECMAScript module file using a `/`-prefixed path rooted at the Swift target directory. + case module(String) } /// A macro that exposes Swift functions, classes, and methods to JavaScript. @@ -141,6 +144,7 @@ public macro JS( /// /// - Parameter from: Selects where the property is read from. /// Use `.global` to read from `globalThis` (e.g. `console`, `document`). +/// Use `.module("/path/to/module.js")` to read a named export from a file rooted at the Swift target. @attached(accessor) public macro JSGetter(jsName: String? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSGetterMacro") @@ -180,6 +184,7 @@ public macro JSSetter(jsName: String? = nil, from: JSImportFrom? = nil) = /// If not provided, the Swift function name is used. /// - Parameter from: Selects where the function is looked up from. /// Use `.global` to call a function on `globalThis` (e.g. `setTimeout`). +/// Use `.module("/path/to/module.js")` to call a named export from a file rooted at the Swift target. @attached(body) public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSFunctionMacro") @@ -204,6 +209,7 @@ public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = /// /// - Parameter from: Selects where the constructor is looked up from. /// Use `.global` to construct globals like `WebSocket` via `globalThis`. +/// Use `.module("/path/to/module.js")` to construct a named class export from a file rooted at the Swift target. @attached(member, names: named(jsObject), named(init(unsafelyWrapping:))) @attached(extension, conformances: _JSBridgedClass) public macro JSClass(jsName: String? = nil, from: JSImportFrom? = nil) = diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 201c80e22..39de49ca0 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -17010,6 +17010,192 @@ func _$JSClassSupportImports_makeJSClassWithArrayMembers(_ numbers: [Int], _ lab return JSClassWithArrayMembers.bridgeJSLiftReturn(ret) } +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleVersion_get") +fileprivate func bjs_moduleVersion_get_extern() -> Int32 +#else +fileprivate func bjs_moduleVersion_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleVersion_get() -> Int32 { + return bjs_moduleVersion_get_extern() +} + +func _$moduleVersion_get() throws(JSException) -> String { + let ret = bjs_moduleVersion_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleAdd") +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 +#else +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleAdd(_ lhs: Int32, _ rhs: Int32) -> Int32 { + return bjs_moduleAdd_extern(lhs, rhs) +} + +func _$moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int { + let rhsValue = rhs.bridgeJSLowerParameter() + let lhsValue = lhs.bridgeJSLowerParameter() + let ret = bjs_moduleAdd(lhsValue, rhsValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleRenamed") +fileprivate func bjs_moduleRenamed_extern() -> Int32 +#else +fileprivate func bjs_moduleRenamed_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleRenamed() -> Int32 { + return bjs_moduleRenamed_extern() +} + +func _$moduleRenamed() throws(JSException) -> String { + let ret = bjs_moduleRenamed() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleThrow") +fileprivate func bjs_moduleThrow_extern() -> Void +#else +fileprivate func bjs_moduleThrow_extern() -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleThrow() -> Void { + return bjs_moduleThrow_extern() +} + +func _$moduleThrow() throws(JSException) -> Void { + bjs_moduleThrow() + if let error = _swift_js_take_exception() { + throw error + } +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_init") +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_init(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_create_static") +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_create_static(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_value_get") +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_get(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_value_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_value_set") +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +#else +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { + return bjs_ModuleCounter_value_set_extern(self, newValue) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_increment") +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_increment_extern(self) +} + +func _$ModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_create(_ value: Int) throws(JSException) -> ModuleCounter { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ModuleCounter.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { + let newValueValue = newValue.bridgeJSLowerParameter() + let selfValue = self.bridgeJSLowerParameter() + bjs_ModuleCounter_value_set(selfValue, newValueValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +func _$ModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_increment(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + #if arch(wasm32) @_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_JSTypedArrayImports_jsCreateUint8Array_static") fileprivate func bjs_JSTypedArrayImports_jsCreateUint8Array_static_extern() -> Int32 diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 0eac614c7..e0c30c428 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24174,6 +24174,205 @@ } ] }, + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "name" : "moduleAdd", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "name" : "rhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "renamedFunction", + "name" : "moduleRenamed", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "name" : "moduleThrow", + "parameters" : [ + + ], + "returnType" : { + "void" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "version", + "name" : "moduleVersion", + "type" : { + "string" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ] + }, + "from" : "\/Modules\/ModuleCounter.mjs", + "getters" : [ + { + "accessLevel" : "internal", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "increment", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ModuleCounter", + "setters" : [ + { + "accessLevel" : "internal", + "functionName" : "value_set", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "staticMethods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "create", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "jsObject" : { + "_0" : "ModuleCounter" + } + } + } + ] + } + ] + }, { "functions" : [ diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift new file mode 100644 index 000000000..4cc228328 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -0,0 +1,47 @@ +import JavaScriptKit +import XCTest + +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int + +@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) +func moduleRenamed() throws(JSException) -> String + +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +func moduleThrow() throws(JSException) + +@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) +var moduleVersion: String + +@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +struct ModuleCounter { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ModuleCounter + @JSFunction func increment() throws(JSException) -> Int + @JSGetter var value: Int + @JSSetter func setValue(_ value: Int) throws(JSException) +} + +final class JSImportModuleTests: XCTestCase { + func testModuleFunctionAndGetter() throws { + XCTAssertEqual(try moduleAdd(20, 22), 42) + XCTAssertEqual(try moduleRenamed(), "loaded from a module") + XCTAssertEqual(try moduleVersion, "module-v1") + } + + func testModuleFunctionPropagatesJavaScriptException() { + XCTAssertThrowsError(try moduleThrow()) { error in + XCTAssertTrue(error is JSException) + } + } + + func testModuleClassStaticAndInstanceAPIs() throws { + let constructed = try ModuleCounter(3) + XCTAssertEqual(try constructed.increment(), 4) + try constructed.setValue(9) + XCTAssertEqual(try constructed.value, 9) + + let created = try ModuleCounter.create(40) + XCTAssertEqual(try created.increment(), 41) + } +} diff --git a/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs new file mode 100644 index 000000000..834537e10 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs @@ -0,0 +1,13 @@ +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "loaded from a module"; +} + +export function moduleThrow() { + throw new Error("module failure"); +} + +export const version = "module-v1"; diff --git a/Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs b/Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs new file mode 100644 index 000000000..5a33b7ffa --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs @@ -0,0 +1,17 @@ +export class ModuleCounter { + constructor(value) { + this.value = value; + } + + static create(value) { + return new ModuleCounter(value); + } + + increment() { + return ++this.value; + } + + setValue(value) { + this.value = value; + } +}