add JS-snippet support using from: .module("/path/to/js-module.js") syntax - #792
add JS-snippet support using from: .module("/path/to/js-module.js") syntax#792sliemeobn wants to merge 5 commits into
from: .module("/path/to/js-module.js") syntax#792Conversation
|
closes #508 |
There was a problem hiding this comment.
Nice feature — the design fits the existing patterns well and test coverage is strong. Details in the inline comments.
Two tiny things not worth their own threads: the registry error messages render a double slash (TestModule//Modules/x.mjs — path already starts with /), and a runtime test for a throwing module export would pin down the setException path too.
| ) | ||
| packageInputs.append( | ||
| make.addTask(inputFiles: skeletons + [selfPath], output: bridgeModules) { _, scope in | ||
| let output = try linkBridgeJS(scope) |
There was a problem hiding this comment.
Splitting this into a second task means linkBridgeJS (decode all skeletons + full codegen) runs twice on every build — even for the majority of projects with zero modules, which also end up with an empty bridge-js-modules/ directory in their output package.
I get why the split is attractive: a separate task output means cleanEverything removes the directory. Memoizing the link result keeps that benefit without the double work — skeletons can't change within one build() invocation and MiniMake runs tasks sequentially, so the cache is safe:
var cachedLinkOutput: BridgeJSLinkOutput?
let linkBridgeJS: (MiniMake.VariableScope) throws -> BridgeJSLinkOutput = { scope in
if let cachedLinkOutput { return cachedLinkOutput }
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))
}
let output = try link.link()
cachedLinkOutput = output
return output
}| for skeleton in skeletons { | ||
| for module in skeleton.imported?.modules ?? [] { | ||
| guard module.path.hasPrefix("/") else { | ||
| throw BridgeJSLinkError( |
There was a problem hiding this comment.
relativePath is spliced straight into outputDir.appending(path:) in PackagingPlanner. makeJavaScriptModuleFileLookup guarantees no traversal for locally generated skeletons, so this is safe today — but skeletons also arrive from dependency packages' committed BridgeJS.json, and a path like /../../evil.js in one of those would write outside the output directory (and emit an escaping import line). Cheap to close here since we're already validating:
guard module.path.hasPrefix("/"),
!module.path.split(separator: "/").contains("..")
else {
throw BridgeJSLinkError(
message: "JavaScript module path must start with '/' and must not contain '..': \(module.path)"
)
}| ``` | ||
|
|
||
| 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. | ||
|
|
There was a problem hiding this comment.
This PR itself had to add "Modules" to the exclude: list in Package.swift, and anyone following these docs will hit the same thing — SwiftPM's "found N file(s) which are unhandled" warning the moment they drop a .js file under their target. Worth calling out once here (and probably in the overview article too), e.g. right after this paragraph:
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")]
)
```| /// 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 |
There was a problem hiding this comment.
Every .js/.mjs anywhere under the target becomes a declared build-command input and is appended to the tool's argv, referenced or not. For a target that vendors JS assets (or has a stray node_modules), that's a lot of spurious rebuild triggers and a long command line — and a directory named foo.js currently passes the filter, only to fail later at String(contentsOf:) with a confusing error. The sibling recursivelyCollectSwiftFiles in BridgeJSTool.swift checks isRegularFile; doing the same here plus a small skip-list would cover it:
private let skippedDirectoryNames: Set<String> = ["node_modules"]
func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] {
guard
let enumerator = FileManager.default.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
)
else { return [] }
var files: [URL] = []
for case let fileURL as URL in enumerator {
if skippedDirectoryNames.contains(fileURL.lastPathComponent) {
enumerator.skipDescendants()
continue
}
guard isJavaScriptModuleFile(fileURL),
(try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
else { continue }
files.append(fileURL)
}
return files.sorted { $0.path < $1.path }
}(.build and other dot-directories are already covered by .skipsHiddenFiles.)
|
|
||
| You can bring JavaScript into Swift in two ways: | ||
| You can bring JavaScript into Swift in three ways: | ||
|
|
There was a problem hiding this comment.
Tiny one: the intro at line 11 of this article still says imports work in "two ways", so the article now disagrees with itself:
You can import JavaScript APIs into Swift in three ways:
kateinoigakukun
left a comment
There was a problem hiding this comment.
IIUC the current approach requires developers to run the build plugins and link steps to reflect their changes against snippet js files into the final output, right? This is another challenge I wanted to explore to have faster iterations.
| /// A target-local ECMAScript module embedded into a BridgeJS skeleton. | ||
| public struct ImportedJSModule: Codable, Equatable, Sendable { | ||
| public let path: String | ||
| public let source: String |
There was a problem hiding this comment.
This is a bit concerning. Given that we commit skeleton files when we do AoT codegen, it's not ideal if we need to update skeleton files whenever we change snippet js files. I think we can include only relative path to the js files.
allow to ship JS code as the source for imports (in addition to ".global" and explicitly provided imports)
I noticed that currently there is no diagnostic when
from:is used in places that do not support it, I did not add these checks (eg: class members, setters) to this PR more focussed. (#793)