Skip to content

BridgeJS: Add configuration support with bridge-js.config.json files #416

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Examples/*/package-lock.json
Package.resolved
Plugins/BridgeJS/Sources/JavaScript/package-lock.json
Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/**/*.actual
bridge-js.config.local.json
2 changes: 1 addition & 1 deletion Examples/ImportTS/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PackageDescription
let package = Package(
name: "MyApp",
platforms: [
.macOS(.v10_15),
.macOS(.v11),
.iOS(.v13),
.tvOS(.v13),
.watchOS(.v6),
Expand Down
16 changes: 8 additions & 8 deletions Examples/ImportTS/Sources/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@ import JavaScriptKit

// This function is automatically generated by the @JS plugin
// It demonstrates how to use TypeScript functions and types imported from bridge-js.d.ts
@JS public func run() {
@JS public func run() throws(JSException) {
// Call the imported consoleLog function defined in bridge-js.d.ts
consoleLog("Hello, World!")
try consoleLog("Hello, World!")

// Get the document object - this comes from the imported getDocument() function
let document = getDocument()
let document = try getDocument()

// Access and modify properties - the title property is read/write
document.title = "Hello, World!"
try document.setTitle("Hello, World!")

// Access read-only properties - body is defined as readonly in TypeScript
let body = document.body
let body = try document.body

// Create a new element using the document.createElement method
let h1 = document.createElement("h1")
let h1 = try document.createElement("h1")

// Set properties on the created element
h1.innerText = "Hello, World!"
try h1.setInnerText("Hello, World!")

// Call methods on objects - appendChild is defined in the HTMLElement interface
body.appendChild(h1)
try body.appendChild(h1)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ struct BridgeJSBuildPlugin: BuildToolPlugin {
executable: try context.tool(named: "BridgeJSTool").url,
arguments: [
"import",
"--target-dir",
target.directoryURL.path,
"--output-skeleton",
outputSkeletonPath.path,
"--output-swift",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ extension BridgeJSCommandPlugin.Context {
try runBridgeJSTool(
arguments: [
"import",
"--target-dir",
target.directoryURL.path,
"--output-skeleton",
generatedJavaScriptDirectory.appending(path: "BridgeJS.ImportTS.json").path,
"--output-swift",
Expand Down
55 changes: 55 additions & 0 deletions Plugins/BridgeJS/Sources/BridgeJSCore/BridgeJSConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import struct Foundation.URL
import struct Foundation.Data
import class Foundation.FileManager
import class Foundation.JSONDecoder

/// Configuration file representation for BridgeJS.
public struct BridgeJSConfig: Codable {
/// A mapping of tool names to their override paths.
///
/// If not present, the tool will be searched for in the system PATH.
public var tools: [String: String]?

/// Load the configuration file from the SwiftPM package target directory.
///
/// Files are loaded **in this order** and merged (later files override earlier ones):
/// 1. `bridge-js.config.json`
/// 2. `bridge-js.config.local.json`
public static func load(targetDirectory: URL) throws -> BridgeJSConfig {
// Define file paths in priority order: base first, then local overrides
let files = [
targetDirectory.appendingPathComponent("bridge-js.config.json"),
targetDirectory.appendingPathComponent("bridge-js.config.local.json"),
]

var config = BridgeJSConfig()

for file in files {
do {
if let loaded = try loadConfig(from: file) {
config = config.merging(overrides: loaded)
}
} catch {
throw BridgeJSCoreError("Failed to parse \(file.path): \(error)")
}
}

return config
}

/// Load a config file from the given URL if it exists, otherwise return nil
private static func loadConfig(from url: URL) throws -> BridgeJSConfig? {
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(BridgeJSConfig.self, from: data)
}

/// Merge the current configuration with the overrides.
func merging(overrides: BridgeJSConfig) -> BridgeJSConfig {
return BridgeJSConfig(
tools: (tools ?? [:]).merging(overrides.tools ?? [:], uniquingKeysWith: { $1 })
)
}
}
13 changes: 11 additions & 2 deletions Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
@preconcurrency import var Foundation.stderr
@preconcurrency import struct Foundation.URL
@preconcurrency import struct Foundation.Data
@preconcurrency import struct Foundation.ObjCBool
@preconcurrency import class Foundation.JSONEncoder
@preconcurrency import class Foundation.FileManager
@preconcurrency import class Foundation.JSONDecoder
@preconcurrency import class Foundation.ProcessInfo
import SwiftParser

#if canImport(BridgeJSCore)
Expand Down Expand Up @@ -50,7 +52,7 @@ import TS2Skeleton
do {
try run()
} catch {
printStderr("Error: \(error)")
printStderr("error: \(error)")
exit(1)
}
}
Expand Down Expand Up @@ -83,6 +85,10 @@ import TS2Skeleton
help: "Print verbose output",
required: false
),
"target-dir": OptionRule(
help: "The SwiftPM package target directory",
required: true
),
"output-swift": OptionRule(help: "The output file path for the Swift source code", required: true),
"output-skeleton": OptionRule(
help: "The output file path for the skeleton of the imported TypeScript APIs",
Expand All @@ -99,6 +105,9 @@ import TS2Skeleton
)
let progress = ProgressReporting(verbose: doubleDashOptions["verbose"] == "true")
var importer = ImportTS(progress: progress, moduleName: doubleDashOptions["module-name"]!)
let targetDirectory = URL(fileURLWithPath: doubleDashOptions["target-dir"]!)
let config = try BridgeJSConfig.load(targetDirectory: targetDirectory)
let nodePath: URL = try config.findTool("node", targetDirectory: targetDirectory)
for inputFile in positionalArguments {
if inputFile.hasSuffix(".json") {
let sourceURL = URL(fileURLWithPath: inputFile)
Expand All @@ -109,7 +118,7 @@ import TS2Skeleton
importer.addSkeleton(skeleton)
} else if inputFile.hasSuffix(".d.ts") {
let tsconfigPath = URL(fileURLWithPath: doubleDashOptions["project"]!)
try importer.addSourceFile(inputFile, tsconfigPath: tsconfigPath.path)
try importer.addSourceFile(inputFile, tsconfigPath: tsconfigPath.path, nodePath: nodePath)
}
}

Expand Down
33 changes: 29 additions & 4 deletions Plugins/BridgeJS/Sources/TS2Skeleton/TS2Skeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import BridgeJSSkeleton
internal func which(
_ executable: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) throws -> URL {
) -> URL? {
func checkCandidate(_ candidate: URL) -> Bool {
var isDirectory: ObjCBool = false
let fileExists = FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory)
Expand Down Expand Up @@ -51,13 +51,38 @@ internal func which(
return url
}
}
throw BridgeJSCoreError("Executable \(executable) not found in PATH")
return nil
}

extension BridgeJSConfig {
/// Find a tool from the system PATH, using environment variable override, or bridge-js.config.json
public func findTool(_ name: String, targetDirectory: URL) throws -> URL {
if let tool = tools?[name] {
return URL(fileURLWithPath: tool)
}
if let url = which(name) {
return url
}

// Emit a helpful error message with a suggestion to create a local config override.
throw BridgeJSCoreError(
"""
Executable "\(name)" not found in PATH. \
Hint: Try setting the JAVASCRIPTKIT_\(name.uppercased().replacingOccurrences(of: "-", with: "_"))_EXEC environment variable, \
or create a local config override with:
echo '{ "tools": { "\(name)": "'$(which \(name))'" } }' > \(targetDirectory.appendingPathComponent("bridge-js.config.local.json").path)
"""
)
}
}

extension ImportTS {
/// Processes a TypeScript definition file and extracts its API information
public mutating func addSourceFile(_ sourceFile: String, tsconfigPath: String) throws {
let nodePath = try which("node")
public mutating func addSourceFile(
_ sourceFile: String,
tsconfigPath: String,
nodePath: URL
) throws {
let ts2skeletonPath = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("JavaScript")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftParser
import Testing
@testable import BridgeJSLink
@testable import BridgeJSCore
@testable import TS2Skeleton

@Suite struct BridgeJSLinkTests {
private func snapshot(
Expand Down Expand Up @@ -65,7 +66,8 @@ import Testing
let tsconfigPath = url.deletingLastPathComponent().appendingPathComponent("tsconfig.json")

var importTS = ImportTS(progress: .silent, moduleName: "TestModule")
try importTS.addSourceFile(url.path, tsconfigPath: tsconfigPath.path)
let nodePath = try #require(which("node"))
try importTS.addSourceFile(url.path, tsconfigPath: tsconfigPath.path, nodePath: nodePath)
let name = url.deletingPathExtension().deletingPathExtension().lastPathComponent

let encoder = JSONEncoder()
Expand Down
3 changes: 2 additions & 1 deletion Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportTSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import Foundation
func snapshot(input: String) throws {
var api = ImportTS(progress: .silent, moduleName: "Check")
let url = Self.inputsDirectory.appendingPathComponent(input)
let nodePath = try #require(which("node"))
let tsconfigPath = url.deletingLastPathComponent().appendingPathComponent("tsconfig.json")
try api.addSourceFile(url.path, tsconfigPath: tsconfigPath.path)
try api.addSourceFile(url.path, tsconfigPath: tsconfigPath.path, nodePath: nodePath)
let outputSwift = try #require(try api.finalize())
let name = url.deletingPathExtension().deletingPathExtension().deletingPathExtension().lastPathComponent
try assertSnapshot(
Expand Down
34 changes: 11 additions & 23 deletions Plugins/BridgeJS/Tests/BridgeJSToolTests/WhichTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import Foundation

let environment = ["PATH": tempDir.path]

let result = try which("testexec", environment: environment)
let result = try #require(which("testexec", environment: environment))

#expect(result.path == execFile.path)
}
Expand All @@ -48,7 +48,7 @@ import Foundation
let pathEnv = "\(tempDir1.path)\(Self.pathSeparator)\(tempDir2.path)"
let environment = ["PATH": pathEnv]

let result = try which("testexec", environment: environment)
let result = try #require(which("testexec", environment: environment))

// Should return the first one found
#expect(result.path == exec1.path)
Expand All @@ -69,7 +69,7 @@ import Foundation
"JAVASCRIPTKIT_NODE_EXEC": customExec.path,
]

let result = try which("node", environment: environment)
let result = try #require(which("node", environment: environment))

#expect(result.path == customExec.path)
}
Expand All @@ -86,7 +86,7 @@ import Foundation
"JAVASCRIPTKIT_MY_EXEC_EXEC": customExec.path,
]

let result = try which("my-exec", environment: environment)
let result = try #require(which("my-exec", environment: environment))

#expect(result.path == customExec.path)
}
Expand All @@ -109,7 +109,7 @@ import Foundation
"JAVASCRIPTKIT_TESTEXEC_EXEC": envExec.path,
]

let result = try which("testexec", environment: environment)
let result = try #require(which("testexec", environment: environment))

// Should prefer environment variable over PATH
#expect(result.path == envExec.path)
Expand All @@ -122,9 +122,7 @@ import Foundation
@Test func whichThrowsWhenExecutableNotFound() throws {
let environment = ["PATH": "/nonexistent\(Self.pathSeparator)/also/nonexistent"]

#expect(throws: BridgeJSCoreError.self) {
_ = try which("nonexistent_executable_12345", environment: environment)
}
#expect(which("nonexistent_executable_12345", environment: environment) == nil)
}

@Test func whichThrowsWhenEnvironmentPathIsInvalid() throws {
Expand All @@ -137,9 +135,7 @@ import Foundation
"JAVASCRIPTKIT_NOTEXECUTABLE_EXEC": nonExecFile.path,
]

#expect(throws: BridgeJSCoreError.self) {
_ = try which("notexecutable", environment: environment)
}
#expect(which("notexecutable", environment: environment) == nil)
}
}

Expand All @@ -150,9 +146,7 @@ import Foundation
"JAVASCRIPTKIT_TESTEXEC_EXEC": tempDir.path,
]

#expect(throws: BridgeJSCoreError.self) {
_ = try which("testexec", environment: environment)
}
#expect(which("testexec", environment: environment) == nil)
}
}

Expand All @@ -161,17 +155,13 @@ import Foundation
@Test func whichHandlesEmptyPath() throws {
let environment = ["PATH": ""]

#expect(throws: BridgeJSCoreError.self) {
_ = try which("anyexec", environment: environment)
}
#expect(which("anyexec", environment: environment) == nil)
}

@Test func whichHandlesMissingPathEnvironment() throws {
let environment: [String: String] = [:]

#expect(throws: BridgeJSCoreError.self) {
_ = try which("anyexec", environment: environment)
}
#expect(which("anyexec", environment: environment) == nil)
}

@Test func whichIgnoresNonExecutableFiles() throws {
Expand All @@ -182,9 +172,7 @@ import Foundation

let environment = ["PATH": tempDir.path]

#expect(throws: BridgeJSCoreError.self) {
_ = try which("testfile", environment: environment)
}
#expect(which("testfile", environment: environment) == nil)
}
}
}
Loading