From 403d75562829b7ffa40bca797dee50e54fe76096 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:03:16 +0200 Subject: [PATCH 01/10] added .module("path/to/file.js") support for imports --- Package.swift | 1 + Plugins/BridgeJS/Package.swift | 1 + .../BridgeJSBuildPlugin.swift | 5 +- .../BridgeJSCommandPlugin.swift | 1 + .../BridgeJSPluginUtilities | 1 + .../BridgeJSCore/SwiftToSkeleton.swift | 44 ++- .../Sources/BridgeJSLink/BridgeJSLink.swift | 44 ++- .../BridgeJSLink/BridgeJSLinkOutput.swift | 21 ++ .../ImportedJSModuleRegistry.swift | 63 ++++ .../InputDiscovery.swift | 19 ++ .../BridgeJSSkeleton/BridgeJSSkeleton.swift | 54 +++- .../Sources/BridgeJSTool/BridgeJSTool.swift | 48 ++- .../Sources/BridgeJSTool/InputDiscovery.swift | 1 + .../BridgeJSToolInternal.swift | 8 +- .../BridgeJSCodegenTests.swift | 71 ++++- .../BridgeJSToolTests/BridgeJSLinkTests.swift | 29 +- .../BuildPluginInputDiscoveryTests.swift | 33 ++ .../BridgeJSToolTests/DiagnosticsTests.swift | 27 ++ .../Inputs/MacroSwift/JSImportModule.swift | 17 + .../MacroSwift/Modules/JSImportModule.mjs | 27 ++ .../BridgeJSCodegenTests/JSImportModule.json | 198 ++++++++++++ .../BridgeJSCodegenTests/JSImportModule.swift | 166 ++++++++++ .../BridgeJSLinkTests/JSImportModule.d.ts | 21 ++ .../BridgeJSLinkTests/JSImportModule.js | 298 ++++++++++++++++++ .../JSImportModule.modules.js | 28 ++ Plugins/PackageToJS/Sources/PackageToJS.swift | 48 ++- .../Importing-JavaScript-into-Swift.md | 5 +- .../Importing-JS-Class.md | 24 +- .../Importing-JS-Function.md | 16 +- .../Importing-JS-Variable.md | 14 + .../Articles/BridgeJS/Unsupported-Features.md | 8 + Sources/JavaScriptKit/Macros.swift | 8 +- .../Generated/BridgeJS.swift | 169 ++++++++++ .../Generated/JavaScript/BridgeJS.json | 188 +++++++++++ .../JSImportModuleTests.swift | 38 +++ .../Modules/JSImportModule.mjs | 27 ++ 36 files changed, 1727 insertions(+), 44 deletions(-) create mode 120000 Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities create mode 100644 Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift create mode 100644 Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift create mode 100644 Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift create mode 120000 Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js create mode 100644 Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift create mode 100644 Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs 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/Package.swift b/Plugins/BridgeJS/Package.swift index 2074a8a66..a6d4c0c54 100644 --- a/Plugins/BridgeJS/Package.swift +++ b/Plugins/BridgeJS/Package.swift @@ -57,6 +57,7 @@ let package = Package( dependencies: [ "BridgeJSCore", "BridgeJSLink", + "BridgeJSBuildPlugin", "TS2Swift", .product(name: "SwiftParser", package: "swift-syntax"), .product(name: "SwiftSyntax", package: "swift-syntax"), diff --git a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift index b78f5f9a6..da1e84309 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift @@ -27,7 +27,8 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { .map(\.url) let configFile = pathToConfigFile(target: target) - var inputFiles: [URL] = inputSwiftFiles + let inputJavaScriptFiles = discoverJavaScriptModuleFiles(in: target.directoryURL) + var inputFiles: [URL] = inputSwiftFiles + inputJavaScriptFiles if FileManager.default.fileExists(atPath: configFile.path) { inputFiles.append(configFile) } @@ -77,7 +78,7 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { } let allSwiftFiles = inputSwiftFiles + pluginGeneratedSwiftFiles - arguments.append(contentsOf: allSwiftFiles.map(\.path)) + arguments.append(contentsOf: (allSwiftFiles + inputJavaScriptFiles).map(\.path)) return .buildCommand( displayName: "Generate BridgeJS code", diff --git a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift index 24b96f53d..b69950a90 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift @@ -184,6 +184,7 @@ extension BridgeJSCommandPlugin.Context { !$0.url.path.hasPrefix(generatedDirectory.path + "/") }.map(\.url.path) ) + generateArguments.append(contentsOf: discoverJavaScriptModuleFiles(in: target.directoryURL).map(\.path)) generateArguments.append(contentsOf: remainingArguments) try runBridgeJSTool(arguments: generateArguments) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities new file mode 120000 index 000000000..2daaa446e --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities @@ -0,0 +1 @@ +../BridgeJSPluginUtilities \ No newline at end of file diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 5b5155fdc..2197d30f9 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 javaScriptModuleSource: (String) throws -> String? + private var importedJSModules: [String: ImportedJSModule] = [:] /// 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, + javaScriptModuleSource: @escaping (String) throws -> String? = { _ in nil } ) { self.progress = progress self.moduleName = moduleName self.exposeToGlobal = exposeToGlobal self.identityMode = identityMode + self.javaScriptModuleSource = javaScriptModuleSource self.typeDeclResolver = TypeDeclResolver() self.externalModuleIndex = externalModuleIndex @@ -90,6 +94,27 @@ 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 importedJSModules[path] != nil { + continue + } + guard let source = try javaScriptModuleSource(path) else { + importCollector.errors.append( + DiagnosticError( + node: sourceFile, + message: "JavaScript module file was not found at '\(path)'." + ) + ) + continue + } + importedJSModules[path] = ImportedJSModule(path: path, source: source) + } + let exportErrors = exportCollector.errors.filter { $0.severity == .error } let importErrorsFatal = importCollector.errors.filter { $0.severity == .error && !$0.message.contains("Unsupported type '") @@ -128,7 +153,11 @@ public final class SwiftToSkeleton { throw BridgeJSCoreDiagnosticError(diagnostics: diagnostics) } let importedSkeleton: ImportedModuleSkeleton? = { - let module = ImportedModuleSkeleton(children: importedFiles) + let modules = importedJSModules.values.sorted { $0.path < $1.path } + let module = ImportedModuleSkeleton( + children: importedFiles, + modules: modules.isEmpty ? nil : modules + ) if module.children.allSatisfy({ $0.functions.isEmpty && $0.types.isEmpty && $0.globalGetters.isEmpty }) { return nil } @@ -2516,10 +2545,19 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { for argument in arguments { guard argument.label?.text == "from" else { continue } + if let call = argument.expression.as(FunctionCallExprSyntax.self), + call.calledExpression.trimmedDescription.split(separator: ".").last == "module", + call.arguments.count == 1, + let literal = call.arguments.first?.expression.as(StringLiteralExprSyntax.self), + let path = literal.representedLiteralValue + { + return .module(path) + } + // Accept `.global`, `JSImportFrom.global`, etc. let description = argument.expression.trimmedDescription let caseName = description.split(separator: ".").last.map(String.init) ?? description - return JSImportFrom(rawValue: caseName) + return caseName == "global" ? .global : nil } return nil } diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index 4706b14a4..b6bfe1121 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] = [], @@ -1077,6 +1078,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) @@ -1220,8 +1226,9 @@ public struct BridgeJSLink { return printer.lines.joined(separator: "\n") } - public func link() throws -> (outputJs: String, outputDts: String) { + public func link() throws -> BridgeJSLinkOutput { intrinsicRegistry.reset() + try importedModuleRegistry.configure(skeletons: skeletons) intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in guard let skeleton = unified.exported else { return } for klass in skeleton.classes { @@ -1233,7 +1240,11 @@ public struct BridgeJSLink { let data = try collectLinkData() let outputJs = try generateJavaScript(data: data) let outputDts = generateTypeScript(data: data) - return (outputJs, outputDts) + return BridgeJSLinkOutput( + outputJs: outputJs, + outputDts: outputDts, + modules: importedModuleRegistry.artifacts + ) } private func enumHelperAssignments() -> CodeFragmentPrinter { @@ -1586,7 +1597,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 +3471,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 +3498,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 +3556,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 +3604,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 +3651,7 @@ extension BridgeJSLink { } func renderImportedStaticMethod( + swiftModuleName: String, context: ImportedTypeSkeleton, method: ImportedFunctionSkeleton ) throws -> (js: [String], dts: [String]) { @@ -3638,7 +3663,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/BridgeJSLinkOutput.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift new file mode 100644 index 000000000..ac95235af --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift @@ -0,0 +1,21 @@ +public struct BridgeJSLinkOutput: Sendable { + public struct Module: Equatable, Sendable { + public let relativePath: String + public let source: String + + init(relativePath: String, source: String) { + self.relativePath = relativePath + self.source = source + } + } + + public let outputJs: String + public let outputDts: String + public let modules: [Module] + + init(outputJs: String, outputDts: String, modules: [Module]) { + self.outputJs = outputJs + self.outputDts = outputDts + self.modules = modules + } +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift new file mode 100644 index 000000000..5913900e2 --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -0,0 +1,63 @@ +#if canImport(BridgeJSSkeleton) +import BridgeJSSkeleton +#endif + +final class ImportedJSModuleRegistry { + private struct Key: Hashable { + let swiftModuleName: String + let path: String + } + + private var aliases: [Key: String] = [:] + private(set) var artifacts: [BridgeJSLinkOutput.Module] = [] + + func configure(skeletons: [BridgeJSSkeleton]) throws { + aliases.removeAll(keepingCapacity: true) + artifacts.removeAll(keepingCapacity: true) + + var sources: [Key: String] = [:] + for skeleton in skeletons { + for module in skeleton.imported?.modules ?? [] { + let key = Key(swiftModuleName: skeleton.moduleName, path: module.path) + if let existing = sources[key], existing != module.source { + throw BridgeJSLinkError( + message: "Conflicting JavaScript module contents for \(skeleton.moduleName)/\(module.path)" + ) + } + sources[key] = module.source + } + } + + for (index, entry) in sources.sorted(by: { + ($0.key.swiftModuleName, $0.key.path) < ($1.key.swiftModuleName, $1.key.path) + }).enumerated() { + let relativePath = "bridge-js-modules/\(entry.key.swiftModuleName)/\(entry.key.path)" + aliases[entry.key] = "__bjs_imported_module_\(index)" + artifacts.append(.init(relativePath: relativePath, source: entry.value)) + } + } + + func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String { + switch from { + case nil: + return "imports" + case .global: + return "globalThis" + case .module(let path): + let key = Key(swiftModuleName: swiftModuleName, path: path) + guard let alias = aliases[key] else { + throw BridgeJSLinkError( + message: "Missing embedded JavaScript module for \(swiftModuleName)/\(path)" + ) + } + return alias + } + } + + var importLines: [String] { + artifacts.enumerated().map { index, artifact in + let path = BridgeJSLink.escapeForJavaScriptStringLiteral(artifact.relativePath) + return "import * as __bjs_imported_module_\(index) from \"./\(path)\";" + } + } +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift b/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift new file mode 100644 index 000000000..f5ddff956 --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift @@ -0,0 +1,19 @@ +import Foundation + +/// JavaScript files must be declared as build-command inputs so SwiftPM reruns +/// BridgeJS when a referenced module changes. +func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + else { return [] } + + return enumerator.compactMap { $0 as? URL }.filter(isJavaScriptModuleFile).sorted { $0.path < $1.path } +} + +func isJavaScriptModuleFile(_ file: URL) -> Bool { + ["js", "mjs"].contains(file.pathExtension.lowercased()) +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index 7f45b6c39..bca77829a 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -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 { +public enum JSImportFrom: Codable, Equatable, Sendable { case global + case module(String) + + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + self = value == "global" ? .global : .module(value) + } + + 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 + } +} + +/// A target-local ECMAScript module embedded into a BridgeJS skeleton. +public struct ImportedJSModule: Codable, Equatable, Sendable { + public let path: String + public let source: String + + public init(path: String, source: String) { + self.path = path + self.source = source + } } public struct ImportedFunctionSkeleton: Codable { @@ -1455,9 +1482,32 @@ public struct ImportedFileSkeleton: Codable { public struct ImportedModuleSkeleton: Codable { public var children: [ImportedFileSkeleton] + public var modules: [ImportedJSModule]? - public init(children: [ImportedFileSkeleton]) { + public init(children: [ImportedFileSkeleton], modules: [ImportedJSModule]? = nil) { self.children = children + self.modules = modules + } + + /// Deterministic non-cryptographic content fingerprint used to invalidate + /// build-plugin outputs when only an embedded JavaScript module changes. + public var moduleContentFingerprint: String? { + guard let modules, !modules.isEmpty else { return nil } + var hash: UInt64 = 0xcbf29ce484222325 + func combine(_ bytes: S) where S.Element == UInt8 { + for byte in bytes { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + } + for module in modules.sorted(by: { $0.path < $1.path }) { + combine(module.path.utf8) + combine(CollectionOfOne(UInt8(0))) + combine(module.source.utf8) + combine(CollectionOfOne(UInt8(0xff))) + } + let hexadecimal = String(hash, radix: 16) + return String(repeating: "0", count: 16 - hexadecimal.count) + hexadecimal } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index fa8a0a273..38028960c 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -160,6 +160,10 @@ import BridgeJSUtilities var inputFiles = withSpan("Collecting Swift files") { return inputSwiftFiles(targetDirectory: targetDirectory, positionalArguments: positionalArguments) } + let javaScriptModuleFilesByPath = try makeJavaScriptModuleFileLookup( + targetDirectory: targetDirectory, + positionalArguments: positionalArguments + ) // BridgeJS.Macros.swift contains imported declarations (@JSFunction, @JSClass, etc.) that need // to be processed by SwiftToSkeleton to populate the imported skeleton. The command plugin @@ -176,7 +180,17 @@ import BridgeJSUtilities moduleName: moduleName, exposeToGlobal: config.exposeToGlobal, externalModuleIndex: externalModuleIndex, - identityMode: config.identityMode + identityMode: config.identityMode, + javaScriptModuleSource: { path in + guard let fileURL = javaScriptModuleFilesByPath[path] else { + return nil + } + do { + return try String(contentsOf: fileURL, encoding: .utf8) + } catch { + throw BridgeJSToolError("Failed to read JavaScript module '\(path)': \(error)") + } + } ) for inputFile in inputFiles.sorted() { try withSpan("Parsing \(inputFile)") { @@ -236,7 +250,10 @@ import BridgeJSUtilities // Combine and write unified Swift output let outputSwiftURL = outputDirectory.appending(path: "BridgeJS.swift") - let combinedSwift = [closureSupport, exportResult, importResult].compactMap { $0 } + let moduleFingerprint = skeleton.imported?.moduleContentFingerprint.map { + "// BridgeJS JavaScript module fingerprint: \($0)" + } + let combinedSwift = [moduleFingerprint, closureSupport, exportResult, importResult].compactMap { $0 } let outputSwift = combineGeneratedSwift( combinedSwift, importingExternalModules: skeleton.usedExternalModules @@ -396,7 +413,32 @@ 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" } +} + +private func makeJavaScriptModuleFileLookup( + targetDirectory: URL, + positionalArguments: [String] +) throws -> [String: URL] { + let files = + positionalArguments.isEmpty + ? discoverJavaScriptModuleFiles(in: targetDirectory) + : positionalArguments.map { URL(fileURLWithPath: $0) }.filter(isJavaScriptModuleFile) + + let targetPath = targetDirectory.standardizedFileURL.path + let targetPrefix = targetPath.hasSuffix("/") ? targetPath : targetPath + "/" + var modules: [String: URL] = [:] + + for file in files { + let fileURL = file.standardizedFileURL + guard fileURL.path.hasPrefix(targetPrefix) else { + throw BridgeJSToolError("JavaScript module is outside the target directory: \(fileURL.path)") + } + let relativePath = String(fileURL.path.dropFirst(targetPrefix.count)) + modules[relativePath] = fileURL + } + + return modules } extension Profiling { diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift new file mode 120000 index 000000000..c524f2eab --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift @@ -0,0 +1 @@ +../BridgeJSPluginUtilities/InputDiscovery.swift \ No newline at end of file diff --git a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift index 4a58f1972..e23beffdf 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift @@ -103,7 +103,7 @@ import ArgumentParser } } - static func linkSkeletons(skeletonFiles: [String]) throws -> (outputJs: String, outputDts: String) { + static func linkSkeletons(skeletonFiles: [String]) throws -> BridgeJSLinkOutput { var skeletons: [BridgeJSSkeleton] = [] for skeletonFile in skeletonFiles.sorted() { let skeletonData = try readData(from: skeletonFile) @@ -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/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 8b8e8b8a2..5142ee4fd 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,65 @@ import Testing static let multifileInputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .appendingPathComponent("Inputs").appendingPathComponent("MacroSwift").appendingPathComponent("Multifile") + @Test + func legacyImportedModuleSkeletonDecodesWithoutModules() throws { + let decoded = try JSONDecoder().decode( + ImportedModuleSkeleton.self, + from: Data(#"{"children":[]}"#.utf8) + ) + #expect(decoded.children.isEmpty) + #expect(decoded.modules == nil) + } + + @Test + func changingOnlyJavaScriptModuleContentsUpdatesGeneratedArtifacts() 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 + """ + + func generate(source: String) throws -> (BridgeJSSkeleton, String, BridgeJSLinkOutput) { + var moduleLoadCount = 0 + let generator = SwiftToSkeleton( + progress: .silent, + moduleName: "TestModule", + exposeToGlobal: false, + externalModuleIndex: .empty, + javaScriptModuleSource: { + moduleLoadCount += 1 + return $0 == modulePath ? source : nil + } + ) + generator.addSourceFile(Parser.parse(source: swiftSource), inputFilePath: "Imports.swift") + let skeleton = try generator.finalize() + let imported = try #require(skeleton.imported) + let generatedThunks = try ImportTS( + progress: .silent, + moduleName: skeleton.moduleName, + skeleton: imported + ).finalize() + let swiftThunks = try #require(generatedThunks) + let linked = try BridgeJSLink(skeletons: [skeleton]).link() + #expect(moduleLoadCount == 1) + return (skeleton, swiftThunks, linked) + } + + let firstSource = "export const version = 'one'; export function add(a, b) { return a + b; }" + let secondSource = "export const version = 'two'; export function add(a, b) { return a + b; }" + let first = try generate(source: firstSource) + let second = try generate(source: secondSource) + + #expect(first.1 == second.1) + #expect(first.0.imported?.moduleContentFingerprint != second.0.imported?.moduleContentFingerprint) + #expect(first.2.modules.map(\.relativePath) == second.2.modules.map(\.relativePath)) + #expect(first.2.modules.map(\.source) == [firstSource]) + #expect(second.2.modules.map(\.source) == [secondSource]) + } + private func snapshotCodegen( skeleton: BridgeJSSkeleton, name: String, @@ -77,11 +137,20 @@ 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 moduleSource = + input == "JSImportModule.swift" + ? try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), + encoding: .utf8 + ) + + : nil let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, - externalModuleIndex: .empty + externalModuleIndex: .empty, + javaScriptModuleSource: { $0 == "Modules/JSImportModule.mjs" ? moduleSource : nil } ) 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..8098a2feb 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,9 +28,22 @@ import Testing filePath: filePath, function: function, sourceLocation: sourceLocation, - input: outputDts.data(using: .utf8)!, + input: output.outputDts.data(using: .utf8)!, fileExtension: "d.ts" ) + if !output.modules.isEmpty { + let moduleDump = output.modules.map { module in + "// \(module.relativePath)\n\(module.source)" + }.joined(separator: "\n") + try assertSnapshot( + name: name, + filePath: filePath, + function: function, + sourceLocation: sourceLocation, + input: Data(moduleDump.utf8), + fileExtension: "modules.js" + ) + } } static let inputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent( @@ -49,11 +62,19 @@ import Testing let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) + let moduleSource = + input == "JSImportModule.swift" + ? try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), + encoding: .utf8 + ) + : nil let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, - externalModuleIndex: .empty + externalModuleIndex: .empty, + javaScriptModuleSource: { $0 == "Modules/JSImportModule.mjs" ? moduleSource : nil } ) importSwift.addSourceFile(sourceFile, inputFilePath: "\(name).swift") let importResult = try importSwift.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift new file mode 100644 index 000000000..c2921f7c1 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import BridgeJSBuildPlugin + +@Suite struct BuildPluginInputDiscoveryTests { + @Test + func discoversJavaScriptModuleInputsInStableOrder() throws { + let targetDirectory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: targetDirectory) } + + let nested = targetDirectory.appending(path: "Nested") + let hidden = targetDirectory.appending(path: ".hidden") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: hidden, withIntermediateDirectories: true) + + for path in ["z.js", "Nested/a.mjs", "Nested/b.JS", "ignored.swift", ".hidden/hidden.js"] { + #expect( + FileManager.default.createFile( + atPath: targetDirectory.appending(path: path).path, + contents: Data("input".utf8) + ) + ) + } + + let resolvedTargetPath = targetDirectory.resolvingSymlinksInPath().path + let relativePaths = discoverJavaScriptModuleFiles(in: targetDirectory).map { + String($0.resolvingSymlinksInPath().path.dropFirst(resolvedTargetPath.count + 1)) + } + #expect(relativePaths == ["Nested/a.mjs", "Nested/b.JS", "z.js"]) + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index 79ea47ebd..1c8170e06 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,32 @@ 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 = "@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'")) + } + /// 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..57f024700 --- /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(jsName: "ModuleCounter", from: .module("Modules/JSImportModule.mjs")) +struct ImportedModuleCounter { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ImportedModuleCounter + @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..366c2d53e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs @@ -0,0 +1,27 @@ +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "renamed"; +} + +export const version = "1.0"; + +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..a24ab9e3d --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json @@ -0,0 +1,198 @@ +{ + "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\/JSImportModule.mjs", + "getters" : [ + { + "accessLevel" : "internal", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "jsName" : "ModuleCounter", + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "increment", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ImportedModuleCounter", + "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" : "ImportedModuleCounter" + } + } + } + ] + } + ] + } + ], + "modules" : [ + { + "path" : "Modules\/JSImportModule.mjs", + "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"renamed\";\n}\n\nexport const version = \"1.0\";\n\nexport class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" + } + ] + }, + "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..ae3ab7e46 --- /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 lhsValue = lhs.bridgeJSLowerParameter() + let rhsValue = rhs.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_ImportedModuleCounter_init") +fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_init(_ value: Int32) -> Int32 { + return bjs_ImportedModuleCounter_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ImportedModuleCounter_create_static") +fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_create_static(_ value: Int32) -> Int32 { + return bjs_ImportedModuleCounter_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ImportedModuleCounter_value_get") +fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_value_get(_ self: Int32) -> Int32 { + return bjs_ImportedModuleCounter_value_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ImportedModuleCounter_value_set") +fileprivate func bjs_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +#else +fileprivate func bjs_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { + return bjs_ImportedModuleCounter_value_set_extern(self, newValue) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ImportedModuleCounter_increment") +fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ImportedModuleCounter_increment_extern(self) +} + +func _$ImportedModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ImportedModuleCounter_create(_ value: Int) throws(JSException) -> ImportedModuleCounter { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ImportedModuleCounter.bridgeJSLiftReturn(ret) +} + +func _$ImportedModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ImportedModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { + let selfValue = self.bridgeJSLowerParameter() + let newValueValue = newValue.bridgeJSLowerParameter() + bjs_ImportedModuleCounter_value_set(selfValue, newValueValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +func _$ImportedModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_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..dcafd250e --- /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 ImportedModuleCounter { + 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..88b02582b --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js @@ -0,0 +1,298 @@ +// 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"; + +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_ImportedModuleCounter_init"] = function bjs_ImportedModuleCounter_init(value) { + try { + return swift.memory.retain(new __bjs_imported_module_0.ModuleCounter(value)); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ImportedModuleCounter_value_get"] = function bjs_ImportedModuleCounter_value_get(self) { + try { + let ret = swift.memory.getObject(self).value; + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ImportedModuleCounter_value_set"] = function bjs_ImportedModuleCounter_value_set(self, newValue) { + try { + swift.memory.getObject(self).value = newValue; + } catch (error) { + setException(error); + } + } + TestModule["bjs_ImportedModuleCounter_create_static"] = function bjs_ImportedModuleCounter_create_static(value) { + try { + let ret = __bjs_imported_module_0.ModuleCounter.create(value); + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ImportedModuleCounter_increment"] = function bjs_ImportedModuleCounter_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/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js new file mode 100644 index 000000000..73d9c74ba --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js @@ -0,0 +1,28 @@ +// bridge-js-modules/TestModule/Modules/JSImportModule.mjs +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "renamed"; +} + +export const version = "1.0"; + +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/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index ff3e2ce5a..ef697fe7a 100644 --- a/Plugins/PackageToJS/Sources/PackageToJS.swift +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -281,6 +281,13 @@ protocol PackagingSystem { } extension PackagingSystem { + func recreateDirectory(atPath: String) throws { + if FileManager.default.fileExists(atPath: atPath) { + try FileManager.default.removeItem(atPath: atPath) + } + try createDirectory(atPath: atPath) + } + func createDirectory(atPath: String) throws { guard !FileManager.default.fileExists(atPath: atPath) else { return } try FileManager.default.createDirectory( @@ -597,21 +604,40 @@ struct PackagingPlanner { if skeletons.count > 0 { 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") + let linkBridgeJS: (MiniMake.VariableScope) throws -> BridgeJSLinkOutput = { scope in + var link = BridgeJSLink( + sharedMemory: Self.isSharedMemoryEnabled(triple: triple) + ) + for skeletonPath in skeletons { + let url = URL(fileURLWithPath: scope.resolve(path: skeletonPath).path) + try link.addSkeletonFile(data: Data(contentsOf: url)) + } + return try link.link() + } packageInputs.append( make.addTask(inputFiles: skeletons + [selfPath], output: bridgeJs) { _, scope in - var link = BridgeJSLink( - sharedMemory: Self.isSharedMemoryEnabled(triple: triple) + let output = try linkBridgeJS(scope) + 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) + ) + } + ) + packageInputs.append( + make.addTask(inputFiles: skeletons + [selfPath], output: bridgeModules) { _, scope in + let output = try linkBridgeJS(scope) + let modulesDirectory = scope.resolve(path: bridgeModules) + try system.recreateDirectory(atPath: modulesDirectory.path) + for module in output.modules { + let destination = scope.resolve(path: outputDir.appending(path: module.relativePath)) + try system.createDirectory(atPath: destination.deletingLastPathComponent().path) + try system.writeFile(atPath: destination.path, content: Data(module.source.utf8)) } - - 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)) } ) } 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..b30e728b0 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 @@ -25,10 +25,11 @@ Add the BridgeJS plugin and enable the Extern feature as described in - -- \ 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..3fc34ed2a 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 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..17e0b2860 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,20 @@ 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-relative 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 +``` + +BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. + ### 2. Provide the implementation at initialization Return the corresponding function(s) in the object passed to `getImports()` when initializing the WebAssembly module. @@ -35,7 +49,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..67ac77fb2 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-relative module: + +```javascript +// JavaScript/config.js +export const environment = "production"; +``` + +```swift +@JSGetter(jsName: "environment", from: .module("JavaScript/config.js")) +var currentEnvironment: String +``` + +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`. diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md index 83213aca1..e4b52304a 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, target-relative `.js` or `.mjs` paths and 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..201128db1 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 a target-relative ECMAScript module file. +public enum JSImportFrom { case global + /// Read from an ECMAScript module file located relative to 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 relative to 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 relative to 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 relative to 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..d48c2ca67 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -8,6 +8,8 @@ @_spi(BridgeJS) import JavaScriptKit +// BridgeJS JavaScript module fingerprint: 89f1e5b74a064b7d + #if arch(wasm32) @_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si") fileprivate func invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si_extern(_ callback: Int32, _ param0: Int32) -> Int32 @@ -17010,6 +17012,173 @@ 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 lhsValue = lhs.bridgeJSLowerParameter() + let rhsValue = rhs.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_ImportedModuleCounter_init") +fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_init(_ value: Int32) -> Int32 { + return bjs_ImportedModuleCounter_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ImportedModuleCounter_create_static") +fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_create_static(_ value: Int32) -> Int32 { + return bjs_ImportedModuleCounter_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ImportedModuleCounter_value_get") +fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_value_get(_ self: Int32) -> Int32 { + return bjs_ImportedModuleCounter_value_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ImportedModuleCounter_value_set") +fileprivate func bjs_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +#else +fileprivate func bjs_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { + return bjs_ImportedModuleCounter_value_set_extern(self, newValue) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ImportedModuleCounter_increment") +fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ImportedModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ImportedModuleCounter_increment_extern(self) +} + +func _$ImportedModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ImportedModuleCounter_create(_ value: Int) throws(JSException) -> ImportedModuleCounter { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ImportedModuleCounter.bridgeJSLiftReturn(ret) +} + +func _$ImportedModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ImportedModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { + let selfValue = self.bridgeJSLowerParameter() + let newValueValue = newValue.bridgeJSLowerParameter() + bjs_ImportedModuleCounter_value_set(selfValue, newValueValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +func _$ImportedModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ImportedModuleCounter_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..85754c18e 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24174,6 +24174,188 @@ } ] }, + { + "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\/JSImportModule.mjs", + "getters" : [ + { + "accessLevel" : "internal", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "jsName" : "ModuleCounter", + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "increment", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ImportedModuleCounter", + "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" : "ImportedModuleCounter" + } + } + } + ] + } + ] + }, { "functions" : [ @@ -25057,6 +25239,12 @@ } ] } + ], + "modules" : [ + { + "path" : "Modules\/JSImportModule.mjs", + "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"loaded from a module\";\n}\n\nexport const version = \"module-v1\";\n\nexport class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" + } ] }, "moduleName" : "BridgeJSRuntimeTests", diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift new file mode 100644 index 000000000..201d659e9 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -0,0 +1,38 @@ +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 + +@JSGetter(jsName: "version", from: .module("Modules/JSImportModule.mjs")) +var moduleVersion: String + +@JSClass(jsName: "ModuleCounter", from: .module("Modules/JSImportModule.mjs")) +struct ImportedModuleCounter { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ImportedModuleCounter + @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 testModuleClassStaticAndInstanceAPIs() throws { + let constructed = try ImportedModuleCounter(3) + XCTAssertEqual(try constructed.increment(), 4) + try constructed.setValue(9) + XCTAssertEqual(try constructed.value, 9) + + let created = try ImportedModuleCounter.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..df01a16d2 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs @@ -0,0 +1,27 @@ +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "loaded from a module"; +} + +export const version = "module-v1"; + +export class ModuleCounter { + constructor(value) { + this.value = value; + } + + static create(value) { + return new ModuleCounter(value); + } + + increment() { + return ++this.value; + } + + setValue(value) { + this.value = value; + } +} From d563c6798db332146fa2fd8f81b8032c82184bc8 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:40:58 +0200 Subject: [PATCH 02/10] use leading slash --- .../Sources/BridgeJSCore/SwiftToSkeleton.swift | 10 ++++++++++ .../BridgeJSLink/ImportedJSModuleRegistry.swift | 7 ++++++- .../Sources/BridgeJSTool/BridgeJSTool.swift | 4 ++-- .../BridgeJSToolTests/BridgeJSCodegenTests.swift | 8 ++++---- .../BridgeJSToolTests/BridgeJSLinkTests.swift | 16 +++++++++++++++- .../BridgeJSToolTests/DiagnosticsTests.swift | 9 ++++++++- .../Inputs/MacroSwift/JSImportModule.swift | 8 ++++---- .../BridgeJSCodegenTests/JSImportModule.json | 10 +++++----- .../BridgeJS/Importing-JavaScript-into-Swift.md | 2 +- .../Importing-JavaScript/Importing-JS-Class.md | 4 ++-- .../Importing-JS-Function.md | 6 +++--- .../Importing-JS-Variable.md | 6 +++--- .../Articles/BridgeJS/Unsupported-Features.md | 2 +- Sources/JavaScriptKit/Macros.swift | 10 +++++----- .../Generated/BridgeJS.swift | 2 +- .../Generated/JavaScript/BridgeJS.json | 10 +++++----- .../JSImportModuleTests.swift | 8 ++++---- 17 files changed, 79 insertions(+), 43 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 2197d30f9..773f19acb 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -103,6 +103,16 @@ public final class SwiftToSkeleton { if importedJSModules[path] != nil { continue } + guard path.hasPrefix("/") else { + importCollector.errors.append( + DiagnosticError( + node: sourceFile, + message: "JavaScript module paths must start with '/' to indicate the Swift target root: " + + "'\(path)'." + ) + ) + continue + } guard let source = try javaScriptModuleSource(path) else { importCollector.errors.append( DiagnosticError( diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift index 5913900e2..ebc715eac 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -18,6 +18,11 @@ final class ImportedJSModuleRegistry { var sources: [Key: String] = [:] for skeleton in skeletons { for module in skeleton.imported?.modules ?? [] { + guard module.path.hasPrefix("/") else { + throw BridgeJSLinkError( + message: "JavaScript module path must start with '/': \(module.path)" + ) + } let key = Key(swiftModuleName: skeleton.moduleName, path: module.path) if let existing = sources[key], existing != module.source { throw BridgeJSLinkError( @@ -31,7 +36,7 @@ final class ImportedJSModuleRegistry { for (index, entry) in sources.sorted(by: { ($0.key.swiftModuleName, $0.key.path) < ($1.key.swiftModuleName, $1.key.path) }).enumerated() { - let relativePath = "bridge-js-modules/\(entry.key.swiftModuleName)/\(entry.key.path)" + let relativePath = "bridge-js-modules/\(entry.key.swiftModuleName)\(entry.key.path)" aliases[entry.key] = "__bjs_imported_module_\(index)" artifacts.append(.init(relativePath: relativePath, source: entry.value)) } diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index 38028960c..8597a5519 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -434,8 +434,8 @@ private func makeJavaScriptModuleFileLookup( guard fileURL.path.hasPrefix(targetPrefix) else { throw BridgeJSToolError("JavaScript module is outside the target directory: \(fileURL.path)") } - let relativePath = String(fileURL.path.dropFirst(targetPrefix.count)) - modules[relativePath] = fileURL + let targetRootedPath = "/\(fileURL.path.dropFirst(targetPrefix.count))" + modules[targetRootedPath] = fileURL } return modules diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 5142ee4fd..b252b982d 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -26,12 +26,12 @@ import Testing @Test func changingOnlyJavaScriptModuleContentsUpdatesGeneratedArtifacts() throws { - let modulePath = "Modules/math.mjs" + let modulePath = "/Modules/math.mjs" let swiftSource = """ - @JSFunction(from: .module("Modules/math.mjs")) + @JSFunction(from: .module("/Modules/math.mjs")) func add(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int - @JSGetter(jsName: "version", from: .module("Modules/math.mjs")) + @JSGetter(jsName: "version", from: .module("/Modules/math.mjs")) var moduleVersion: String """ @@ -150,7 +150,7 @@ import Testing moduleName: "TestModule", exposeToGlobal: false, externalModuleIndex: .empty, - javaScriptModuleSource: { $0 == "Modules/JSImportModule.mjs" ? moduleSource : nil } + javaScriptModuleSource: { $0 == "/Modules/JSImportModule.mjs" ? moduleSource : nil } ) 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 8098a2feb..3d51432f6 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -56,6 +56,20 @@ import Testing return inputs.filter { $0.hasSuffix(`extension`) } } + @Test + func rejectsNonRootedJavaScriptModulePath() { + let skeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [], + modules: [ImportedJSModule(path: "Modules/module.js", source: "")] + ) + ) + #expect(throws: BridgeJSLinkError.self) { + try BridgeJSLink(skeletons: [skeleton]).link() + } + } + @Test(arguments: collectInputs(extension: ".swift")) func snapshot(input: String) throws { let url = Self.inputsDirectory.appendingPathComponent(input) @@ -74,7 +88,7 @@ import Testing moduleName: "TestModule", exposeToGlobal: false, externalModuleIndex: .empty, - javaScriptModuleSource: { $0 == "Modules/JSImportModule.mjs" ? moduleSource : nil } + javaScriptModuleSource: { $0 == "/Modules/JSImportModule.mjs" ? moduleSource : nil } ) 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 1c8170e06..bdc9c98e2 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -28,9 +28,16 @@ import Testing @Test func missingJavaScriptModuleProducesDiagnostic() throws { + let source = "@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'")) + } + + @Test + func javaScriptModulePathMustStartAtTargetRoot() throws { let source = "@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("JavaScript module paths must start with '/'")) } /// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics. diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift index 57f024700..34b6b1caa 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift @@ -1,13 +1,13 @@ -@JSFunction(from: .module("Modules/JSImportModule.mjs")) +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int -@JSFunction(jsName: "renamedFunction", from: .module("Modules/JSImportModule.mjs")) +@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) func moduleRenamed() throws(JSException) -> String -@JSGetter(jsName: "version", from: .module("Modules/JSImportModule.mjs")) +@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) var moduleVersion: String -@JSClass(jsName: "ModuleCounter", from: .module("Modules/JSImportModule.mjs")) +@JSClass(jsName: "ModuleCounter", from: .module("/Modules/JSImportModule.mjs")) struct ImportedModuleCounter { @JSFunction init(_ value: Int) throws(JSException) @JSFunction static func create(_ value: Int) throws(JSException) -> ImportedModuleCounter diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json index a24ab9e3d..d28583320 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json @@ -10,7 +10,7 @@ "isStatic" : false, "isThrows" : true }, - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "name" : "moduleAdd", "parameters" : [ { @@ -52,7 +52,7 @@ "isStatic" : false, "isThrows" : true }, - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "jsName" : "renamedFunction", "name" : "moduleRenamed", "parameters" : [ @@ -68,7 +68,7 @@ "globalGetters" : [ { "accessLevel" : "internal", - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "jsName" : "version", "name" : "moduleVersion", "type" : { @@ -97,7 +97,7 @@ } ] }, - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "getters" : [ { "accessLevel" : "internal", @@ -186,7 +186,7 @@ ], "modules" : [ { - "path" : "Modules\/JSImportModule.mjs", + "path" : "\/Modules\/JSImportModule.mjs", "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"renamed\";\n}\n\nexport const version = \"1.0\";\n\nexport class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" } ] 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 b30e728b0..daf031008 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 @@ -29,7 +29,7 @@ You can bring JavaScript into Swift in three ways: - **Inject at initialization**: Declare in Swift and supply the implementation in `getImports()` (e.g. a `today()` function). - **Import from `globalThis`**: For APIs on the JavaScript global object (e.g. `console`, `document`), use `@JSGetter(from: .global)` so they are read from `globalThis` and you don't pass them in `getImports()`. -- **Ship an ECMAScript module**: Use `from: .module("target/relative/path.js")` on a top-level function/getter or `@JSClass`. BridgeJS copies the referenced file into the generated package, so it is not supplied through `getImports()`. +- **Ship an ECMAScript module**: Use `from: .module("/path/from/target/root.js")` on a top-level function/getter or `@JSClass`. The leading `/` denotes the Swift target root; it is not a filesystem-absolute path. BridgeJS copies the referenced file into the generated package, so it is not supplied through `getImports()`. ```swift import JavaScriptKit 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 3fc34ed2a..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 @@ -36,7 +36,7 @@ export class Greeter { ``` ```swift -@JSClass(from: .module("JavaScript/greeter.js")) +@JSClass(from: .module("/JavaScript/greeter.js")) struct Greeter { @JSFunction init(_ name: String) throws(JSException) @JSFunction static func named(_ name: String) throws(JSException) -> Greeter @@ -44,7 +44,7 @@ struct Greeter { } ``` -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. +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 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 17e0b2860..301b9d5ac 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,7 +17,7 @@ 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-relative path: +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 @@ -25,11 +25,11 @@ export function add(a, b) { return a + b; } ``` ```swift -@JSFunction(from: .module("JavaScript/math.js")) +@JSFunction(from: .module("/JavaScript/math.js")) func add(_ a: Double, _ b: Double) throws(JSException) -> Double ``` -BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. +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 embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. ### 2. Provide the implementation at initialization 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 67ac77fb2..7c721259b 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,7 +17,7 @@ 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-relative module: +A top-level getter can also read a named export from a target-rooted module: ```javascript // JavaScript/config.js @@ -25,11 +25,11 @@ export const environment = "production"; ``` ```swift -@JSGetter(jsName: "environment", from: .module("JavaScript/config.js")) +@JSGetter(jsName: "environment", from: .module("/JavaScript/config.js")) var currentEnvironment: String ``` -Module exports are read-only through this API. Top-level `@JSSetter` remains unsupported. +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) diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md index e4b52304a..238bc8687 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md @@ -8,7 +8,7 @@ BridgeJS generates glue code per Swift target (module). Some patterns that are v ## File-backed JavaScript modules -Files referenced by `JSImportFrom.module` must be nonempty, target-relative `.js` or `.mjs` paths and 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. +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. diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 201128db1..7a1bb4091 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -9,10 +9,10 @@ public enum JSEnumStyle: String { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -/// - `module`: Read a named export from a target-relative ECMAScript module file. +/// - `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 located relative to the Swift target directory. + /// Read from an ECMAScript module file using a `/`-prefixed path rooted at the Swift target directory. case module(String) } @@ -144,7 +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 relative to the Swift target. +/// 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") @@ -184,7 +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 relative to the Swift target. +/// 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") @@ -209,7 +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 relative to the Swift target. +/// 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 d48c2ca67..96eb3cc83 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -8,7 +8,7 @@ @_spi(BridgeJS) import JavaScriptKit -// BridgeJS JavaScript module fingerprint: 89f1e5b74a064b7d +// BridgeJS JavaScript module fingerprint: 2acd02db34648b2a #if arch(wasm32) @_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si") diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 85754c18e..1bb8edc84 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24183,7 +24183,7 @@ "isStatic" : false, "isThrows" : true }, - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "name" : "moduleAdd", "parameters" : [ { @@ -24225,7 +24225,7 @@ "isStatic" : false, "isThrows" : true }, - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "jsName" : "renamedFunction", "name" : "moduleRenamed", "parameters" : [ @@ -24241,7 +24241,7 @@ "globalGetters" : [ { "accessLevel" : "internal", - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "jsName" : "version", "name" : "moduleVersion", "type" : { @@ -24270,7 +24270,7 @@ } ] }, - "from" : "Modules\/JSImportModule.mjs", + "from" : "\/Modules\/JSImportModule.mjs", "getters" : [ { "accessLevel" : "internal", @@ -25242,7 +25242,7 @@ ], "modules" : [ { - "path" : "Modules\/JSImportModule.mjs", + "path" : "\/Modules\/JSImportModule.mjs", "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"loaded from a module\";\n}\n\nexport const version = \"module-v1\";\n\nexport class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" } ] diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift index 201d659e9..fa95c3a2d 100644 --- a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -1,16 +1,16 @@ import JavaScriptKit import XCTest -@JSFunction(from: .module("Modules/JSImportModule.mjs")) +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int -@JSFunction(jsName: "renamedFunction", from: .module("Modules/JSImportModule.mjs")) +@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) func moduleRenamed() throws(JSException) -> String -@JSGetter(jsName: "version", from: .module("Modules/JSImportModule.mjs")) +@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) var moduleVersion: String -@JSClass(jsName: "ModuleCounter", from: .module("Modules/JSImportModule.mjs")) +@JSClass(jsName: "ModuleCounter", from: .module("/Modules/JSImportModule.mjs")) struct ImportedModuleCounter { @JSFunction init(_ value: Int) throws(JSException) @JSFunction static func create(_ value: Int) throws(JSException) -> ImportedModuleCounter From cf8df17e233fb0cb029ebf8dd7c37da65747eb14 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:53:56 +0200 Subject: [PATCH 03/10] multi-module test --- .../BridgeJSCodegenTests.swift | 21 ++++-- .../BridgeJSToolTests/BridgeJSLinkTests.swift | 20 +++-- .../Inputs/MacroSwift/JSImportModule.swift | 6 +- .../MacroSwift/Modules/JSImportModule.mjs | 18 ----- .../MacroSwift/Modules/ModuleCounter.mjs | 17 +++++ .../BridgeJSCodegenTests/JSImportModule.json | 13 ++-- .../BridgeJSCodegenTests/JSImportModule.swift | 72 +++++++++--------- .../BridgeJSLinkTests/JSImportModule.d.ts | 2 +- .../BridgeJSLinkTests/JSImportModule.js | 15 ++-- .../JSImportModule.modules.js | 1 + .../Generated/BridgeJS.swift | 74 +++++++++---------- .../Generated/JavaScript/BridgeJS.json | 13 ++-- .../JSImportModuleTests.swift | 10 +-- .../Modules/JSImportModule.mjs | 18 ----- .../Modules/ModuleCounter.mjs | 17 +++++ 15 files changed, 167 insertions(+), 150 deletions(-) create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs create mode 100644 Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index b252b982d..4b09831f1 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -137,20 +137,25 @@ 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 moduleSource = + let moduleSources = input == "JSImportModule.swift" - ? try String( - contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), - encoding: .utf8 - ) - - : nil + ? [ + "/Modules/JSImportModule.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), + encoding: .utf8 + ), + "/Modules/ModuleCounter.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/ModuleCounter.mjs"), + encoding: .utf8 + ), + ] + : [:] let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, externalModuleIndex: .empty, - javaScriptModuleSource: { $0 == "/Modules/JSImportModule.mjs" ? moduleSource : nil } + javaScriptModuleSource: { moduleSources[$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 3d51432f6..3df96c3ff 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -76,19 +76,25 @@ import Testing let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) - let moduleSource = + let moduleSources = input == "JSImportModule.swift" - ? try String( - contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), - encoding: .utf8 - ) - : nil + ? [ + "/Modules/JSImportModule.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), + encoding: .utf8 + ), + "/Modules/ModuleCounter.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/ModuleCounter.mjs"), + encoding: .utf8 + ), + ] + : [:] let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, externalModuleIndex: .empty, - javaScriptModuleSource: { $0 == "/Modules/JSImportModule.mjs" ? moduleSource : nil } + javaScriptModuleSource: { moduleSources[$0] } ) importSwift.addSourceFile(sourceFile, inputFilePath: "\(name).swift") let importResult = try importSwift.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift index 34b6b1caa..3fd9d79e7 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift @@ -7,10 +7,10 @@ func moduleRenamed() throws(JSException) -> String @JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) var moduleVersion: String -@JSClass(jsName: "ModuleCounter", from: .module("/Modules/JSImportModule.mjs")) -struct ImportedModuleCounter { +@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +struct ModuleCounter { @JSFunction init(_ value: Int) throws(JSException) - @JSFunction static func create(_ value: Int) throws(JSException) -> ImportedModuleCounter + @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 index 366c2d53e..3107e222f 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs @@ -7,21 +7,3 @@ export function renamedFunction() { } export const version = "1.0"; - -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/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 index d28583320..084cae56a 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json @@ -97,7 +97,7 @@ } ] }, - "from" : "\/Modules\/JSImportModule.mjs", + "from" : "\/Modules\/ModuleCounter.mjs", "getters" : [ { "accessLevel" : "internal", @@ -112,7 +112,6 @@ } } ], - "jsName" : "ModuleCounter", "methods" : [ { "accessLevel" : "internal", @@ -135,7 +134,7 @@ } } ], - "name" : "ImportedModuleCounter", + "name" : "ModuleCounter", "setters" : [ { "accessLevel" : "internal", @@ -175,7 +174,7 @@ ], "returnType" : { "jsObject" : { - "_0" : "ImportedModuleCounter" + "_0" : "ModuleCounter" } } } @@ -187,7 +186,11 @@ "modules" : [ { "path" : "\/Modules\/JSImportModule.mjs", - "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"renamed\";\n}\n\nexport const version = \"1.0\";\n\nexport class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" + "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"renamed\";\n}\n\nexport const version = \"1.0\";\n" + }, + { + "path" : "\/Modules\/ModuleCounter.mjs", + "source" : "export class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" } ] }, diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift index ae3ab7e46..de63eec20 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift @@ -61,104 +61,104 @@ func _$moduleRenamed() throws(JSException) -> String { } #if arch(wasm32) -@_extern(wasm, module: "TestModule", name: "bjs_ImportedModuleCounter_init") -fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_init") +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_init(_ value: Int32) -> Int32 { - return bjs_ImportedModuleCounter_init_extern(value) +@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_ImportedModuleCounter_create_static") -fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_create_static") +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_create_static(_ value: Int32) -> Int32 { - return bjs_ImportedModuleCounter_create_static_extern(value) +@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_ImportedModuleCounter_value_get") -fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_value_get") +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_value_get(_ self: Int32) -> Int32 { - return bjs_ImportedModuleCounter_value_get_extern(self) +@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_ImportedModuleCounter_value_set") -fileprivate func bjs_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +@_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_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { - return bjs_ImportedModuleCounter_value_set_extern(self, newValue) +@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_ImportedModuleCounter_increment") -fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_increment") +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_increment(_ self: Int32) -> Int32 { - return bjs_ImportedModuleCounter_increment_extern(self) +@inline(never) fileprivate func bjs_ModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_increment_extern(self) } -func _$ImportedModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { +func _$ModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { let valueValue = value.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_init(valueValue) + let ret = bjs_ModuleCounter_init(valueValue) if let error = _swift_js_take_exception() { throw error } return JSObject.bridgeJSLiftReturn(ret) } -func _$ImportedModuleCounter_create(_ value: Int) throws(JSException) -> ImportedModuleCounter { +func _$ModuleCounter_create(_ value: Int) throws(JSException) -> ModuleCounter { let valueValue = value.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_create_static(valueValue) + let ret = bjs_ModuleCounter_create_static(valueValue) if let error = _swift_js_take_exception() { throw error } - return ImportedModuleCounter.bridgeJSLiftReturn(ret) + return ModuleCounter.bridgeJSLiftReturn(ret) } -func _$ImportedModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { +func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { let selfValue = self.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_value_get(selfValue) + let ret = bjs_ModuleCounter_value_get(selfValue) if let error = _swift_js_take_exception() { throw error } return Int.bridgeJSLiftReturn(ret) } -func _$ImportedModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { +func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { let selfValue = self.bridgeJSLowerParameter() let newValueValue = newValue.bridgeJSLowerParameter() - bjs_ImportedModuleCounter_value_set(selfValue, newValueValue) + bjs_ModuleCounter_value_set(selfValue, newValueValue) if let error = _swift_js_take_exception() { throw error } } -func _$ImportedModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { +func _$ModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { let selfValue = self.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_increment(selfValue) + let ret = bjs_ModuleCounter_increment(selfValue) if let error = _swift_js_take_exception() { throw error } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts index dcafd250e..624691d83 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts @@ -4,7 +4,7 @@ // To update this file, just rebuild your project or run // `swift package bridge-js`. -export interface ImportedModuleCounter { +export interface ModuleCounter { increment(): number; value: number; } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js index 88b02582b..cb4767f03 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js @@ -5,6 +5,7 @@ // `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; @@ -233,15 +234,15 @@ export async function createInstantiator(options, swift) { setException(error); } } - TestModule["bjs_ImportedModuleCounter_init"] = function bjs_ImportedModuleCounter_init(value) { + TestModule["bjs_ModuleCounter_init"] = function bjs_ModuleCounter_init(value) { try { - return swift.memory.retain(new __bjs_imported_module_0.ModuleCounter(value)); + return swift.memory.retain(new __bjs_imported_module_1.ModuleCounter(value)); } catch (error) { setException(error); return 0 } } - TestModule["bjs_ImportedModuleCounter_value_get"] = function bjs_ImportedModuleCounter_value_get(self) { + TestModule["bjs_ModuleCounter_value_get"] = function bjs_ModuleCounter_value_get(self) { try { let ret = swift.memory.getObject(self).value; return ret; @@ -250,23 +251,23 @@ export async function createInstantiator(options, swift) { return 0 } } - TestModule["bjs_ImportedModuleCounter_value_set"] = function bjs_ImportedModuleCounter_value_set(self, newValue) { + 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_ImportedModuleCounter_create_static"] = function bjs_ImportedModuleCounter_create_static(value) { + TestModule["bjs_ModuleCounter_create_static"] = function bjs_ModuleCounter_create_static(value) { try { - let ret = __bjs_imported_module_0.ModuleCounter.create(value); + let ret = __bjs_imported_module_1.ModuleCounter.create(value); return swift.memory.retain(ret); } catch (error) { setException(error); return 0 } } - TestModule["bjs_ImportedModuleCounter_increment"] = function bjs_ImportedModuleCounter_increment(self) { + TestModule["bjs_ModuleCounter_increment"] = function bjs_ModuleCounter_increment(self) { try { let ret = swift.memory.getObject(self).increment(); return ret; diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js index 73d9c74ba..78325f2bc 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js @@ -9,6 +9,7 @@ export function renamedFunction() { export const version = "1.0"; +// bridge-js-modules/TestModule/Modules/ModuleCounter.mjs export class ModuleCounter { constructor(value) { this.value = value; diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 96eb3cc83..36956f32f 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -8,7 +8,7 @@ @_spi(BridgeJS) import JavaScriptKit -// BridgeJS JavaScript module fingerprint: 2acd02db34648b2a +// BridgeJS JavaScript module fingerprint: 76f49b4593a4f170 #if arch(wasm32) @_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si") @@ -17075,104 +17075,104 @@ func _$moduleRenamed() throws(JSException) -> String { } #if arch(wasm32) -@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ImportedModuleCounter_init") -fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_init") +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_init_extern(_ value: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_init(_ value: Int32) -> Int32 { - return bjs_ImportedModuleCounter_init_extern(value) +@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_ImportedModuleCounter_create_static") -fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_create_static") +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_create_static_extern(_ value: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_create_static(_ value: Int32) -> Int32 { - return bjs_ImportedModuleCounter_create_static_extern(value) +@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_ImportedModuleCounter_value_get") -fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_value_get") +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_value_get_extern(_ self: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_value_get(_ self: Int32) -> Int32 { - return bjs_ImportedModuleCounter_value_get_extern(self) +@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_ImportedModuleCounter_value_set") -fileprivate func bjs_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +@_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_ImportedModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { - return bjs_ImportedModuleCounter_value_set_extern(self, newValue) +@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_ImportedModuleCounter_increment") -fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_increment") +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 #else -fileprivate func bjs_ImportedModuleCounter_increment_extern(_ self: Int32) -> Int32 { +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 { fatalError("Only available on WebAssembly") } #endif -@inline(never) fileprivate func bjs_ImportedModuleCounter_increment(_ self: Int32) -> Int32 { - return bjs_ImportedModuleCounter_increment_extern(self) +@inline(never) fileprivate func bjs_ModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_increment_extern(self) } -func _$ImportedModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { +func _$ModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { let valueValue = value.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_init(valueValue) + let ret = bjs_ModuleCounter_init(valueValue) if let error = _swift_js_take_exception() { throw error } return JSObject.bridgeJSLiftReturn(ret) } -func _$ImportedModuleCounter_create(_ value: Int) throws(JSException) -> ImportedModuleCounter { +func _$ModuleCounter_create(_ value: Int) throws(JSException) -> ModuleCounter { let valueValue = value.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_create_static(valueValue) + let ret = bjs_ModuleCounter_create_static(valueValue) if let error = _swift_js_take_exception() { throw error } - return ImportedModuleCounter.bridgeJSLiftReturn(ret) + return ModuleCounter.bridgeJSLiftReturn(ret) } -func _$ImportedModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { +func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { let selfValue = self.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_value_get(selfValue) + let ret = bjs_ModuleCounter_value_get(selfValue) if let error = _swift_js_take_exception() { throw error } return Int.bridgeJSLiftReturn(ret) } -func _$ImportedModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { +func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { let selfValue = self.bridgeJSLowerParameter() let newValueValue = newValue.bridgeJSLowerParameter() - bjs_ImportedModuleCounter_value_set(selfValue, newValueValue) + bjs_ModuleCounter_value_set(selfValue, newValueValue) if let error = _swift_js_take_exception() { throw error } } -func _$ImportedModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { +func _$ModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { let selfValue = self.bridgeJSLowerParameter() - let ret = bjs_ImportedModuleCounter_increment(selfValue) + let ret = bjs_ModuleCounter_increment(selfValue) if let error = _swift_js_take_exception() { throw error } diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 1bb8edc84..8080e78f4 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24270,7 +24270,7 @@ } ] }, - "from" : "\/Modules\/JSImportModule.mjs", + "from" : "\/Modules\/ModuleCounter.mjs", "getters" : [ { "accessLevel" : "internal", @@ -24285,7 +24285,6 @@ } } ], - "jsName" : "ModuleCounter", "methods" : [ { "accessLevel" : "internal", @@ -24308,7 +24307,7 @@ } } ], - "name" : "ImportedModuleCounter", + "name" : "ModuleCounter", "setters" : [ { "accessLevel" : "internal", @@ -24348,7 +24347,7 @@ ], "returnType" : { "jsObject" : { - "_0" : "ImportedModuleCounter" + "_0" : "ModuleCounter" } } } @@ -25243,7 +25242,11 @@ "modules" : [ { "path" : "\/Modules\/JSImportModule.mjs", - "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"loaded from a module\";\n}\n\nexport const version = \"module-v1\";\n\nexport class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" + "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"loaded from a module\";\n}\n\nexport const version = \"module-v1\";\n" + }, + { + "path" : "\/Modules\/ModuleCounter.mjs", + "source" : "export class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" } ] }, diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift index fa95c3a2d..61a61215a 100644 --- a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -10,10 +10,10 @@ func moduleRenamed() throws(JSException) -> String @JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) var moduleVersion: String -@JSClass(jsName: "ModuleCounter", from: .module("/Modules/JSImportModule.mjs")) -struct ImportedModuleCounter { +@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +struct ModuleCounter { @JSFunction init(_ value: Int) throws(JSException) - @JSFunction static func create(_ value: Int) throws(JSException) -> ImportedModuleCounter + @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) @@ -27,12 +27,12 @@ final class JSImportModuleTests: XCTestCase { } func testModuleClassStaticAndInstanceAPIs() throws { - let constructed = try ImportedModuleCounter(3) + let constructed = try ModuleCounter(3) XCTAssertEqual(try constructed.increment(), 4) try constructed.setValue(9) XCTAssertEqual(try constructed.value, 9) - let created = try ImportedModuleCounter.create(40) + 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 index df01a16d2..6aa166d88 100644 --- a/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs +++ b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs @@ -7,21 +7,3 @@ export function renamedFunction() { } export const version = "module-v1"; - -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/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; + } +} From 7b8d3bf673a4568c6d63e134574e3d702d26b9d4 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:49:02 +0200 Subject: [PATCH 04/10] annoying warning --- .../BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index bca77829a..76b998adf 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 From 6e3beb3fc760ecdf5bc82ffe73a26549b98a75fe Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:01:33 +0200 Subject: [PATCH 05/10] improved diagnostics --- .../BridgeJSCore/SwiftToSkeleton.swift | 64 +++++++++++-------- .../BridgeJSToolTests/DiagnosticsTests.swift | 34 +++++++++- 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 773f19acb..b11e115f6 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -103,10 +103,11 @@ public final class SwiftToSkeleton { if importedJSModules[path] != nil { continue } + let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile) guard path.hasPrefix("/") else { importCollector.errors.append( DiagnosticError( - node: sourceFile, + node: pathNode, message: "JavaScript module paths must start with '/' to indicate the Swift target root: " + "'\(path)'." ) @@ -116,7 +117,7 @@ public final class SwiftToSkeleton { guard let source = try javaScriptModuleSource(path) else { importCollector.errors.append( DiagnosticError( - node: sourceFile, + node: pathNode, message: "JavaScript module file was not found at '\(path)'." ) ) @@ -2452,6 +2453,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 @@ -2546,31 +2548,41 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } 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 { + 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 + } + + 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 } - - if let call = argument.expression.as(FunctionCallExprSyntax.self), - call.calledExpression.trimmedDescription.split(separator: ".").last == "module", - call.arguments.count == 1, - let literal = call.arguments.first?.expression.as(StringLiteralExprSyntax.self), - let path = literal.representedLiteralValue - { - 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 + 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 @@ -2753,7 +2765,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) } @@ -2770,7 +2782,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) } @@ -2964,7 +2976,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) @@ -3017,7 +3029,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/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index bdc9c98e2..1c6ecffaf 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -28,16 +28,46 @@ import Testing @Test func missingJavaScriptModuleProducesDiagnostic() throws { - let source = "@JSFunction(from: .module(\"/missing.js\")) func imported() throws(JSException)" + 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 = "@JSFunction(from: .module(\"missing.js\")) func imported() throws(JSException)" + 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 missingJavaScriptModuleWithTraversalProducesDiagnosticAtPath() 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 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. From 73389ebf09b6191eb02d085806ec87a45d1b4cb2 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:10:05 +0200 Subject: [PATCH 06/10] only use JS-module paths in skeletons, defer file handling to link time --- Plugins/BridgeJS/Package.swift | 1 - .../BridgeJSBuildPlugin.swift | 5 +- .../BridgeJSCommandPlugin.swift | 1 - .../BridgeJSPluginUtilities | 1 - .../BridgeJSCore/SwiftToSkeleton.swift | 39 +++++-- .../Sources/BridgeJSLink/BridgeJSLink.swift | 14 +-- .../BridgeJSLink/BridgeJSLinkOutput.swift | 21 ---- .../ImportedJSModuleRegistry.swift | 59 +++++----- .../InputDiscovery.swift | 19 ---- .../BridgeJSSkeleton/BridgeJSSkeleton.swift | 51 +++------ .../Sources/BridgeJSTool/BridgeJSTool.swift | 46 +------- .../Sources/BridgeJSTool/InputDiscovery.swift | 1 - .../BridgeJSToolInternal.swift | 2 +- .../JavaScriptModulePath.swift | 25 +++++ .../BridgeJSCodegenTests.swift | 87 +++++---------- .../BridgeJSToolTests/BridgeJSLinkTests.swift | 43 +------- .../BuildPluginInputDiscoveryTests.swift | 33 ------ .../BridgeJSToolTests/DiagnosticsTests.swift | 15 ++- .../BridgeJSCodegenTests/JSImportModule.json | 10 -- .../JSImportModule.modules.js | 29 ----- Plugins/PackageToJS/Sources/PackageToJS.swift | 103 ++++++++++++++---- .../Sources/PackageToJSPlugin.swift | 17 ++- .../Tests/PackagingPlannerTests.swift | 98 +++++++++++++++++ .../BridgeJS/Ahead-of-Time-Code-Generation.md | 2 + .../Importing-JavaScript-into-Swift.md | 4 +- .../Importing-JS-Function.md | 12 +- .../Importing-JS-Variable.md | 5 +- .../Generated/BridgeJS.swift | 21 +++- .../Generated/JavaScript/BridgeJS.json | 28 +++-- .../JSImportModuleTests.swift | 9 ++ .../Modules/JSImportModule.mjs | 4 + 31 files changed, 415 insertions(+), 390 deletions(-) delete mode 120000 Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities delete mode 100644 Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift delete mode 100644 Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift delete mode 120000 Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift create mode 100644 Plugins/BridgeJS/Sources/BridgeJSUtilities/JavaScriptModulePath.swift delete mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift delete mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js diff --git a/Plugins/BridgeJS/Package.swift b/Plugins/BridgeJS/Package.swift index a6d4c0c54..2074a8a66 100644 --- a/Plugins/BridgeJS/Package.swift +++ b/Plugins/BridgeJS/Package.swift @@ -57,7 +57,6 @@ let package = Package( dependencies: [ "BridgeJSCore", "BridgeJSLink", - "BridgeJSBuildPlugin", "TS2Swift", .product(name: "SwiftParser", package: "swift-syntax"), .product(name: "SwiftSyntax", package: "swift-syntax"), diff --git a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift index da1e84309..b78f5f9a6 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift @@ -27,8 +27,7 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { .map(\.url) let configFile = pathToConfigFile(target: target) - let inputJavaScriptFiles = discoverJavaScriptModuleFiles(in: target.directoryURL) - var inputFiles: [URL] = inputSwiftFiles + inputJavaScriptFiles + var inputFiles: [URL] = inputSwiftFiles if FileManager.default.fileExists(atPath: configFile.path) { inputFiles.append(configFile) } @@ -78,7 +77,7 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { } let allSwiftFiles = inputSwiftFiles + pluginGeneratedSwiftFiles - arguments.append(contentsOf: (allSwiftFiles + inputJavaScriptFiles).map(\.path)) + arguments.append(contentsOf: allSwiftFiles.map(\.path)) return .buildCommand( displayName: "Generate BridgeJS code", diff --git a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift index b69950a90..24b96f53d 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift @@ -184,7 +184,6 @@ extension BridgeJSCommandPlugin.Context { !$0.url.path.hasPrefix(generatedDirectory.path + "/") }.map(\.url.path) ) - generateArguments.append(contentsOf: discoverJavaScriptModuleFiles(in: target.directoryURL).map(\.path)) generateArguments.append(contentsOf: remainingArguments) try runBridgeJSTool(arguments: generateArguments) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities deleted file mode 120000 index 2daaa446e..000000000 --- a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities +++ /dev/null @@ -1 +0,0 @@ -../BridgeJSPluginUtilities \ No newline at end of file diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index b11e115f6..2d2a3ab6f 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -23,8 +23,8 @@ public final class SwiftToSkeleton { private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = [] private var usedExternalModules = Set() - private let javaScriptModuleSource: (String) throws -> String? - private var importedJSModules: [String: ImportedJSModule] = [:] + 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)] = [] @@ -35,13 +35,13 @@ public final class SwiftToSkeleton { exposeToGlobal: Bool, externalModuleIndex: ExternalModuleIndex, identityMode: String? = nil, - javaScriptModuleSource: @escaping (String) throws -> String? = { _ in nil } + javaScriptModuleExists: @escaping (String) throws -> Bool = { _ in false } ) { self.progress = progress self.moduleName = moduleName self.exposeToGlobal = exposeToGlobal self.identityMode = identityMode - self.javaScriptModuleSource = javaScriptModuleSource + self.javaScriptModuleExists = javaScriptModuleExists self.typeDeclResolver = TypeDeclResolver() self.externalModuleIndex = externalModuleIndex @@ -100,7 +100,7 @@ public final class SwiftToSkeleton { + importCollector.importedGlobalGetters.compactMap(\.from) let modulePaths = Set(importOrigins.compactMap(\.modulePath)) for path in modulePaths.sorted() { - if importedJSModules[path] != nil { + if validatedJavaScriptModulePaths.contains(path) { continue } let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile) @@ -114,7 +114,26 @@ public final class SwiftToSkeleton { ) continue } - guard let source = try javaScriptModuleSource(path) else { + 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, @@ -123,7 +142,7 @@ public final class SwiftToSkeleton { ) continue } - importedJSModules[path] = ImportedJSModule(path: path, source: source) + validatedJavaScriptModulePaths.insert(path) } let exportErrors = exportCollector.errors.filter { $0.severity == .error } @@ -164,11 +183,7 @@ public final class SwiftToSkeleton { throw BridgeJSCoreDiagnosticError(diagnostics: diagnostics) } let importedSkeleton: ImportedModuleSkeleton? = { - let modules = importedJSModules.values.sorted { $0.path < $1.path } - let module = ImportedModuleSkeleton( - children: importedFiles, - modules: modules.isEmpty ? nil : modules - ) + let module = ImportedModuleSkeleton(children: importedFiles) if module.children.allSatisfy({ $0.functions.isEmpty && $0.types.isEmpty && $0.globalGetters.isEmpty }) { return nil } diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index b6bfe1121..d8a1ac780 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -41,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 @@ -1226,9 +1228,9 @@ public struct BridgeJSLink { return printer.lines.joined(separator: "\n") } - public func link() throws -> BridgeJSLinkOutput { + public func link() throws -> (outputJs: String, outputDts: String) { intrinsicRegistry.reset() - try importedModuleRegistry.configure(skeletons: skeletons) + importedModuleRegistry.configure(skeletons: skeletons) intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in guard let skeleton = unified.exported else { return } for klass in skeleton.classes { @@ -1240,11 +1242,7 @@ public struct BridgeJSLink { let data = try collectLinkData() let outputJs = try generateJavaScript(data: data) let outputDts = generateTypeScript(data: data) - return BridgeJSLinkOutput( - outputJs: outputJs, - outputDts: outputDts, - modules: importedModuleRegistry.artifacts - ) + return (outputJs, outputDts) } private func enumHelperAssignments() -> CodeFragmentPrinter { diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift deleted file mode 100644 index ac95235af..000000000 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift +++ /dev/null @@ -1,21 +0,0 @@ -public struct BridgeJSLinkOutput: Sendable { - public struct Module: Equatable, Sendable { - public let relativePath: String - public let source: String - - init(relativePath: String, source: String) { - self.relativePath = relativePath - self.source = source - } - } - - public let outputJs: String - public let outputDts: String - public let modules: [Module] - - init(outputJs: String, outputDts: String, modules: [Module]) { - self.outputJs = outputJs - self.outputDts = outputDts - self.modules = modules - } -} diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift index ebc715eac..2c5716030 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -3,42 +3,41 @@ import BridgeJSSkeleton #endif final class ImportedJSModuleRegistry { - private struct Key: Hashable { + struct Reference: Hashable { let swiftModuleName: String let path: String + + var relativeOutputPath: String { + "bridge-js-modules/\(swiftModuleName)\(path)" + } } - private var aliases: [Key: String] = [:] - private(set) var artifacts: [BridgeJSLinkOutput.Module] = [] + private var aliases: [Reference: String] = [:] + private(set) var references: [Reference] = [] - func configure(skeletons: [BridgeJSSkeleton]) throws { + func configure(skeletons: [BridgeJSSkeleton]) { aliases.removeAll(keepingCapacity: true) - artifacts.removeAll(keepingCapacity: true) + references = Self.collectReferences(skeletons: skeletons) + for (index, reference) in references.enumerated() { + aliases[reference] = "__bjs_imported_module_\(index)" + } + } - var sources: [Key: String] = [:] + static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] { + var references = Set() for skeleton in skeletons { - for module in skeleton.imported?.modules ?? [] { - guard module.path.hasPrefix("/") else { - throw BridgeJSLinkError( - message: "JavaScript module path must start with '/': \(module.path)" - ) - } - let key = Key(swiftModuleName: skeleton.moduleName, path: module.path) - if let existing = sources[key], existing != module.source { - throw BridgeJSLinkError( - message: "Conflicting JavaScript module contents for \(skeleton.moduleName)/\(module.path)" - ) + 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)) } - sources[key] = module.source } } - - for (index, entry) in sources.sorted(by: { - ($0.key.swiftModuleName, $0.key.path) < ($1.key.swiftModuleName, $1.key.path) - }).enumerated() { - let relativePath = "bridge-js-modules/\(entry.key.swiftModuleName)\(entry.key.path)" - aliases[entry.key] = "__bjs_imported_module_\(index)" - artifacts.append(.init(relativePath: relativePath, source: entry.value)) + return references.sorted { + ($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path) } } @@ -49,10 +48,10 @@ final class ImportedJSModuleRegistry { case .global: return "globalThis" case .module(let path): - let key = Key(swiftModuleName: swiftModuleName, path: path) - guard let alias = aliases[key] else { + let reference = Reference(swiftModuleName: swiftModuleName, path: path) + guard let alias = aliases[reference] else { throw BridgeJSLinkError( - message: "Missing embedded JavaScript module for \(swiftModuleName)/\(path)" + message: "Missing JavaScript module \(swiftModuleName)\(path)" ) } return alias @@ -60,8 +59,8 @@ final class ImportedJSModuleRegistry { } var importLines: [String] { - artifacts.enumerated().map { index, artifact in - let path = BridgeJSLink.escapeForJavaScriptStringLiteral(artifact.relativePath) + 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/BridgeJSPluginUtilities/InputDiscovery.swift b/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift deleted file mode 100644 index f5ddff956..000000000 --- a/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -/// JavaScript files must be declared as build-command inputs so SwiftPM reruns -/// BridgeJS when a referenced module changes. -func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] { - guard - let enumerator = FileManager.default.enumerator( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - else { return [] } - - return enumerator.compactMap { $0 as? URL }.filter(isJavaScriptModuleFile).sorted { $0.path < $1.path } -} - -func isJavaScriptModuleFile(_ file: URL) -> Bool { - ["js", "mjs"].contains(file.pathExtension.lowercased()) -} diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index 76b998adf..c70ccdd8b 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -1126,13 +1126,24 @@ private struct AsyncClosureReturnTypeCollector: BridgeSkeletonVisitor { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. +/// - `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 value = try decoder.singleValueContainer().decode(String.self) - self = value == "global" ? .global : .module(value) + 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 { @@ -1146,17 +1157,6 @@ public enum JSImportFrom: Codable, Equatable, Sendable { } } -/// A target-local ECMAScript module embedded into a BridgeJS skeleton. -public struct ImportedJSModule: Codable, Equatable, Sendable { - public let path: String - public let source: String - - public init(path: String, source: String) { - self.path = path - self.source = source - } -} - public struct ImportedFunctionSkeleton: Codable { public let name: String /// The JavaScript function/method name to call, if different from `name`. @@ -1482,32 +1482,9 @@ public struct ImportedFileSkeleton: Codable { public struct ImportedModuleSkeleton: Codable { public var children: [ImportedFileSkeleton] - public var modules: [ImportedJSModule]? - public init(children: [ImportedFileSkeleton], modules: [ImportedJSModule]? = nil) { + public init(children: [ImportedFileSkeleton]) { self.children = children - self.modules = modules - } - - /// Deterministic non-cryptographic content fingerprint used to invalidate - /// build-plugin outputs when only an embedded JavaScript module changes. - public var moduleContentFingerprint: String? { - guard let modules, !modules.isEmpty else { return nil } - var hash: UInt64 = 0xcbf29ce484222325 - func combine(_ bytes: S) where S.Element == UInt8 { - for byte in bytes { - hash ^= UInt64(byte) - hash &*= 0x100000001b3 - } - } - for module in modules.sorted(by: { $0.path < $1.path }) { - combine(module.path.utf8) - combine(CollectionOfOne(UInt8(0))) - combine(module.source.utf8) - combine(CollectionOfOne(UInt8(0xff))) - } - let hexadecimal = String(hash, radix: 16) - return String(repeating: "0", count: 16 - hexadecimal.count) + hexadecimal } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index 8597a5519..140ebda63 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -160,10 +160,6 @@ import BridgeJSUtilities var inputFiles = withSpan("Collecting Swift files") { return inputSwiftFiles(targetDirectory: targetDirectory, positionalArguments: positionalArguments) } - let javaScriptModuleFilesByPath = try makeJavaScriptModuleFileLookup( - targetDirectory: targetDirectory, - positionalArguments: positionalArguments - ) // BridgeJS.Macros.swift contains imported declarations (@JSFunction, @JSClass, etc.) that need // to be processed by SwiftToSkeleton to populate the imported skeleton. The command plugin @@ -181,15 +177,11 @@ import BridgeJSUtilities exposeToGlobal: config.exposeToGlobal, externalModuleIndex: externalModuleIndex, identityMode: config.identityMode, - javaScriptModuleSource: { path in - guard let fileURL = javaScriptModuleFilesByPath[path] else { - return nil - } - do { - return try String(contentsOf: fileURL, encoding: .utf8) - } catch { - throw BridgeJSToolError("Failed to read JavaScript module '\(path)': \(error)") + javaScriptModuleExists: { path in + guard let file = JavaScriptModulePath.resolve(path, relativeTo: targetDirectory) else { + return false } + return JavaScriptModulePath.isRegularFile(at: file) } ) for inputFile in inputFiles.sorted() { @@ -250,10 +242,7 @@ import BridgeJSUtilities // Combine and write unified Swift output let outputSwiftURL = outputDirectory.appending(path: "BridgeJS.swift") - let moduleFingerprint = skeleton.imported?.moduleContentFingerprint.map { - "// BridgeJS JavaScript module fingerprint: \($0)" - } - let combinedSwift = [moduleFingerprint, closureSupport, exportResult, importResult].compactMap { $0 } + let combinedSwift = [closureSupport, exportResult, importResult].compactMap { $0 } let outputSwift = combineGeneratedSwift( combinedSwift, importingExternalModules: skeleton.usedExternalModules @@ -416,31 +405,6 @@ private func inputSwiftFiles(targetDirectory: URL, positionalArguments: [String] return positionalArguments.filter { URL(fileURLWithPath: $0).pathExtension == "swift" } } -private func makeJavaScriptModuleFileLookup( - targetDirectory: URL, - positionalArguments: [String] -) throws -> [String: URL] { - let files = - positionalArguments.isEmpty - ? discoverJavaScriptModuleFiles(in: targetDirectory) - : positionalArguments.map { URL(fileURLWithPath: $0) }.filter(isJavaScriptModuleFile) - - let targetPath = targetDirectory.standardizedFileURL.path - let targetPrefix = targetPath.hasSuffix("/") ? targetPath : targetPath + "/" - var modules: [String: URL] = [:] - - for file in files { - let fileURL = file.standardizedFileURL - guard fileURL.path.hasPrefix(targetPrefix) else { - throw BridgeJSToolError("JavaScript module is outside the target directory: \(fileURL.path)") - } - let targetRootedPath = "/\(fileURL.path.dropFirst(targetPrefix.count))" - modules[targetRootedPath] = fileURL - } - - return modules -} - extension Profiling { static func make() -> Profiling? { guard let outputPath = ProcessInfo.processInfo.environment["BRIDGEJS_PROFILING"] else { diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift deleted file mode 120000 index c524f2eab..000000000 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift +++ /dev/null @@ -1 +0,0 @@ -../BridgeJSPluginUtilities/InputDiscovery.swift \ No newline at end of file diff --git a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift index e23beffdf..cb6a5481c 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift @@ -103,7 +103,7 @@ import ArgumentParser } } - static func linkSkeletons(skeletonFiles: [String]) throws -> BridgeJSLinkOutput { + static func linkSkeletons(skeletonFiles: [String]) throws -> (outputJs: String, outputDts: String) { var skeletons: [BridgeJSSkeleton] = [] for skeletonFile in skeletonFiles.sorted() { let skeletonData = try readData(from: skeletonFile) 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 4b09831f1..263d796a6 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -15,17 +15,7 @@ import Testing .appendingPathComponent("Inputs").appendingPathComponent("MacroSwift").appendingPathComponent("Multifile") @Test - func legacyImportedModuleSkeletonDecodesWithoutModules() throws { - let decoded = try JSONDecoder().decode( - ImportedModuleSkeleton.self, - from: Data(#"{"children":[]}"#.utf8) - ) - #expect(decoded.children.isEmpty) - #expect(decoded.modules == nil) - } - - @Test - func changingOnlyJavaScriptModuleContentsUpdatesGeneratedArtifacts() throws { + func javaScriptModuleReferencesAreStoredWithoutSourceContents() throws { let modulePath = "/Modules/math.mjs" let swiftSource = """ @JSFunction(from: .module("/Modules/math.mjs")) @@ -34,43 +24,32 @@ import Testing @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) - func generate(source: String) throws -> (BridgeJSSkeleton, String, BridgeJSLinkOutput) { - var moduleLoadCount = 0 - let generator = SwiftToSkeleton( - progress: .silent, - moduleName: "TestModule", - exposeToGlobal: false, - externalModuleIndex: .empty, - javaScriptModuleSource: { - moduleLoadCount += 1 - return $0 == modulePath ? source : nil - } - ) - generator.addSourceFile(Parser.parse(source: swiftSource), inputFilePath: "Imports.swift") - let skeleton = try generator.finalize() - let imported = try #require(skeleton.imported) - let generatedThunks = try ImportTS( - progress: .silent, - moduleName: skeleton.moduleName, - skeleton: imported - ).finalize() - let swiftThunks = try #require(generatedThunks) - let linked = try BridgeJSLink(skeletons: [skeleton]).link() - #expect(moduleLoadCount == 1) - return (skeleton, swiftThunks, linked) - } - - let firstSource = "export const version = 'one'; export function add(a, b) { return a + b; }" - let secondSource = "export const version = 'two'; export function add(a, b) { return a + b; }" - let first = try generate(source: firstSource) - let second = try generate(source: secondSource) + #expect(validationCount == 1) + #expect(imported.children.flatMap(\.functions).first?.from == .module(modulePath)) + #expect(!encoded.contains(#""modules""#)) + } - #expect(first.1 == second.1) - #expect(first.0.imported?.moduleContentFingerprint != second.0.imported?.moduleContentFingerprint) - #expect(first.2.modules.map(\.relativePath) == second.2.modules.map(\.relativePath)) - #expect(first.2.modules.map(\.source) == [firstSource]) - #expect(second.2.modules.map(\.source) == [secondSource]) + @Test + func invalidJSImportFromValueFailsToDecode() { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSImportFrom.self, from: Data(#""module.js""#.utf8)) + } } private func snapshotCodegen( @@ -137,25 +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 moduleSources = + let modulePaths: Set = input == "JSImportModule.swift" ? [ - "/Modules/JSImportModule.mjs": try String( - contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), - encoding: .utf8 - ), - "/Modules/ModuleCounter.mjs": try String( - contentsOf: Self.inputsDirectory.appending(path: "Modules/ModuleCounter.mjs"), - encoding: .utf8 - ), + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", ] - : [:] + : [] let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, externalModuleIndex: .empty, - javaScriptModuleSource: { moduleSources[$0] } + 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 3df96c3ff..0445fe5e1 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -31,19 +31,6 @@ import Testing input: output.outputDts.data(using: .utf8)!, fileExtension: "d.ts" ) - if !output.modules.isEmpty { - let moduleDump = output.modules.map { module in - "// \(module.relativePath)\n\(module.source)" - }.joined(separator: "\n") - try assertSnapshot( - name: name, - filePath: filePath, - function: function, - sourceLocation: sourceLocation, - input: Data(moduleDump.utf8), - fileExtension: "modules.js" - ) - } } static let inputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent( @@ -56,45 +43,25 @@ import Testing return inputs.filter { $0.hasSuffix(`extension`) } } - @Test - func rejectsNonRootedJavaScriptModulePath() { - let skeleton = BridgeJSSkeleton( - moduleName: "TestModule", - imported: ImportedModuleSkeleton( - children: [], - modules: [ImportedJSModule(path: "Modules/module.js", source: "")] - ) - ) - #expect(throws: BridgeJSLinkError.self) { - try BridgeJSLink(skeletons: [skeleton]).link() - } - } - @Test(arguments: collectInputs(extension: ".swift")) func snapshot(input: String) throws { let url = Self.inputsDirectory.appendingPathComponent(input) let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) - let moduleSources = + let modulePaths: Set = input == "JSImportModule.swift" ? [ - "/Modules/JSImportModule.mjs": try String( - contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), - encoding: .utf8 - ), - "/Modules/ModuleCounter.mjs": try String( - contentsOf: Self.inputsDirectory.appending(path: "Modules/ModuleCounter.mjs"), - encoding: .utf8 - ), + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", ] - : [:] + : [] let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, externalModuleIndex: .empty, - javaScriptModuleSource: { moduleSources[$0] } + javaScriptModuleExists: { modulePaths.contains($0) } ) importSwift.addSourceFile(sourceFile, inputFilePath: "\(name).swift") let importResult = try importSwift.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift deleted file mode 100644 index c2921f7c1..000000000 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import Testing - -@testable import BridgeJSBuildPlugin - -@Suite struct BuildPluginInputDiscoveryTests { - @Test - func discoversJavaScriptModuleInputsInStableOrder() throws { - let targetDirectory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) - try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: targetDirectory) } - - let nested = targetDirectory.appending(path: "Nested") - let hidden = targetDirectory.appending(path: ".hidden") - try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: hidden, withIntermediateDirectories: true) - - for path in ["z.js", "Nested/a.mjs", "Nested/b.JS", "ignored.swift", ".hidden/hidden.js"] { - #expect( - FileManager.default.createFile( - atPath: targetDirectory.appending(path: path).path, - contents: Data("input".utf8) - ) - ) - } - - let resolvedTargetPath = targetDirectory.resolvingSymlinksInPath().path - let relativePaths = discoverJavaScriptModuleFiles(in: targetDirectory).map { - String($0.resolvingSymlinksInPath().path.dropFirst(resolvedTargetPath.count + 1)) - } - #expect(relativePaths == ["Nested/a.mjs", "Nested/b.JS", "z.js"]) - } -} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index 1c6ecffaf..e8cf963e3 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -49,13 +49,24 @@ import Testing } @Test - func missingJavaScriptModuleWithTraversalProducesDiagnosticAtPath() throws { + 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 file was not found at '/../missing.js'")) + #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:")) } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json index 084cae56a..d90e88f1d 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json @@ -182,16 +182,6 @@ } ] } - ], - "modules" : [ - { - "path" : "\/Modules\/JSImportModule.mjs", - "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"renamed\";\n}\n\nexport const version = \"1.0\";\n" - }, - { - "path" : "\/Modules\/ModuleCounter.mjs", - "source" : "export class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" - } ] }, "moduleName" : "TestModule", diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js deleted file mode 100644 index 78325f2bc..000000000 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js +++ /dev/null @@ -1,29 +0,0 @@ -// bridge-js-modules/TestModule/Modules/JSImportModule.mjs -export function moduleAdd(lhs, rhs) { - return lhs + rhs; -} - -export function renamedFunction() { - return "renamed"; -} - -export const version = "1.0"; - -// bridge-js-modules/TestModule/Modules/ModuleCounter.mjs -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/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index ef697fe7a..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,11 +282,10 @@ protocol PackagingSystem { } extension PackagingSystem { - func recreateDirectory(atPath: String) throws { + func removeItemIfExists(atPath: String) throws { if FileManager.default.fileExists(atPath: atPath) { try FileManager.default.removeItem(atPath: atPath) } - try createDirectory(atPath: atPath) } func createDirectory(atPath: String) throws { @@ -394,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 @@ -405,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 @@ -426,7 +436,7 @@ struct PackagingPlanner { packageId: String, intermediatesDir: BuildPath, selfPackageDir: BuildPath, - skeletons: [BuildPath], + skeletons: [BridgeJSSkeletonInput], outputDir: BuildPath, wasmProductArtifact: BuildPath, wasmFilename: String, @@ -601,23 +611,15 @@ 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") - let linkBridgeJS: (MiniMake.VariableScope) throws -> BridgeJSLinkOutput = { scope in - var link = BridgeJSLink( - sharedMemory: Self.isSharedMemoryEnabled(triple: triple) - ) - for skeletonPath in skeletons { - let url = URL(fileURLWithPath: scope.resolve(path: skeletonPath).path) - try link.addSkeletonFile(data: Data(contentsOf: url)) - } - return try link.link() - } packageInputs.append( - make.addTask(inputFiles: skeletons + [selfPath], output: bridgeJs) { _, scope in - let output = try linkBridgeJS(scope) + 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) @@ -628,16 +630,30 @@ struct PackagingPlanner { ) } ) + let bridgeModulesStamp = intermediatesDir.appending(path: "bridge-js-modules.stamp") packageInputs.append( - make.addTask(inputFiles: skeletons + [selfPath], output: bridgeModules) { _, scope in - let output = try linkBridgeJS(scope) + make.addTask( + inputFiles: skeletonFiles + bridge.modules.map(\.source) + [selfPath], + inputTasks: [outputDirTask, intermediatesDirTask], + output: bridgeModulesStamp + ) { _, scope in let modulesDirectory = scope.resolve(path: bridgeModules) - try system.recreateDirectory(atPath: modulesDirectory.path) - for module in output.modules { - let destination = scope.resolve(path: outputDir.appending(path: module.relativePath)) + try system.removeItemIfExists(atPath: modulesDirectory.path) + if !bridge.modules.isEmpty { + try system.createDirectory(atPath: modulesDirectory.path) + } + for module in bridge.modules { + let destination = scope.resolve(path: outputDir.appending(path: module.relativeOutputPath)) try system.createDirectory(atPath: destination.deletingLastPathComponent().path) - try system.writeFile(atPath: destination.path, content: Data(module.source.utf8)) + try system.syncFile( + from: scope.resolve(path: module.source).path, + to: destination.path + ) } + try system.writeFile( + atPath: scope.resolve(path: bridgeModulesStamp).path, + content: Data() + ) } ) } @@ -671,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/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/Ahead-of-Time-Code-Generation.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md index 776f6f577..0ea13a214 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md @@ -155,6 +155,8 @@ git add Sources/MyApp/Generated git commit -m "Update generated BridgeJS code" ``` +Changing only the contents of a JavaScript module referenced with `from: .module` does not require regenerating the Swift glue or skeleton. PackageToJS tracks the referenced file as an input and copies its current contents into the generated package on the next build. + ## When to Use Each Approach **Use the build plugin** when: 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 daf031008..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. @@ -31,6 +31,8 @@ You can bring JavaScript into Swift in three ways: - **Import from `globalThis`**: For APIs on the JavaScript global object (e.g. `console`, `document`), use `@JSGetter(from: .global)` so they are read from `globalThis` and you don't pass them in `getImports()`. - **Ship an ECMAScript module**: Use `from: .module("/path/from/target/root.js")` on a top-level function/getter or `@JSClass`. The leading `/` denotes the Swift target root; it is not a filesystem-absolute path. BridgeJS copies the referenced file into the generated package, so it is not supplied through `getImports()`. +Exclude the directory containing `.js` or `.mjs` modules from the Swift target to avoid SwiftPM's unhandled-file warning. See for an example. + ```swift import JavaScriptKit 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 301b9d5ac..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 @@ -29,7 +29,17 @@ export function add(a, b) { return a + b; } 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 embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. +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 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 7c721259b..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 @@ -42,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 @@ -57,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/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 36956f32f..181224c14 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -8,8 +8,6 @@ @_spi(BridgeJS) import JavaScriptKit -// BridgeJS JavaScript module fingerprint: 76f49b4593a4f170 - #if arch(wasm32) @_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si") fileprivate func invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si_extern(_ callback: Int32, _ param0: Int32) -> Int32 @@ -17074,6 +17072,25 @@ func _$moduleRenamed() throws(JSException) -> String { 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 diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 8080e78f4..e0c30c428 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24234,6 +24234,24 @@ "returnType" : { "string" : { + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "name" : "moduleThrow", + "parameters" : [ + + ], + "returnType" : { + "void" : { + } } } @@ -25238,16 +25256,6 @@ } ] } - ], - "modules" : [ - { - "path" : "\/Modules\/JSImportModule.mjs", - "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"loaded from a module\";\n}\n\nexport const version = \"module-v1\";\n" - }, - { - "path" : "\/Modules\/ModuleCounter.mjs", - "source" : "export class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" - } ] }, "moduleName" : "BridgeJSRuntimeTests", diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift index 61a61215a..4cc228328 100644 --- a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -7,6 +7,9 @@ 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 @@ -26,6 +29,12 @@ final class JSImportModuleTests: XCTestCase { 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) diff --git a/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs index 6aa166d88..834537e10 100644 --- a/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs +++ b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs @@ -6,4 +6,8 @@ export function renamedFunction() { return "loaded from a module"; } +export function moduleThrow() { + throw new Error("module failure"); +} + export const version = "module-v1"; From 494cf888cc77486b7de9cfc931aebe26ea32399d Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:01:21 +0200 Subject: [PATCH 07/10] very strange things are happening? --- Plugins/PackageToJS/Templates/runtime.mjs | 42 ++++++++++++++--------- 1 file changed, 26 insertions(+), 16 deletions(-) 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); From 936b5c554db12bbe476ecc86b78dcce97b5a2469 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:43:32 +0200 Subject: [PATCH 08/10] fix codegen after rebase --- .../__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift index de63eec20..dc22aceb9 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift @@ -31,8 +31,8 @@ fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 { } func _$moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int { - let lhsValue = lhs.bridgeJSLowerParameter() let rhsValue = rhs.bridgeJSLowerParameter() + let lhsValue = lhs.bridgeJSLowerParameter() let ret = bjs_moduleAdd(lhsValue, rhsValue) if let error = _swift_js_take_exception() { throw error @@ -148,8 +148,8 @@ func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { } func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { - let selfValue = self.bridgeJSLowerParameter() let newValueValue = newValue.bridgeJSLowerParameter() + let selfValue = self.bridgeJSLowerParameter() bjs_ModuleCounter_value_set(selfValue, newValueValue) if let error = _swift_js_take_exception() { throw error From 1d3e210d7eb3d2a759440f17ab5717aa19ffa133 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:46:20 +0200 Subject: [PATCH 09/10] removed doc section about module files --- .../Articles/BridgeJS/Ahead-of-Time-Code-Generation.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md index 0ea13a214..776f6f577 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Ahead-of-Time-Code-Generation.md @@ -155,8 +155,6 @@ git add Sources/MyApp/Generated git commit -m "Update generated BridgeJS code" ``` -Changing only the contents of a JavaScript module referenced with `from: .module` does not require regenerating the Swift glue or skeleton. PackageToJS tracks the referenced file as an input and copies its current contents into the generated package on the next build. - ## When to Use Each Approach **Use the build plugin** when: From 44294b41d6785dae93a9d8fba9f8bdf75b1d0abd Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:54:35 +0200 Subject: [PATCH 10/10] good lord... --- Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 181224c14..39de49ca0 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -17043,8 +17043,8 @@ fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 { } func _$moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int { - let lhsValue = lhs.bridgeJSLowerParameter() let rhsValue = rhs.bridgeJSLowerParameter() + let lhsValue = lhs.bridgeJSLowerParameter() let ret = bjs_moduleAdd(lhsValue, rhsValue) if let error = _swift_js_take_exception() { throw error @@ -17179,8 +17179,8 @@ func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { } func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { - let selfValue = self.bridgeJSLowerParameter() let newValueValue = newValue.bridgeJSLowerParameter() + let selfValue = self.bridgeJSLowerParameter() bjs_ModuleCounter_value_set(selfValue, newValueValue) if let error = _swift_js_take_exception() { throw error