Skip to content

add JS-snippet support using from: .module("/path/to/js-module.js") syntax - #792

Open
sliemeobn wants to merge 5 commits into
swiftwasm:mainfrom
sliemeobn:feature/js-snippets
Open

add JS-snippet support using from: .module("/path/to/js-module.js") syntax#792
sliemeobn wants to merge 5 commits into
swiftwasm:mainfrom
sliemeobn:feature/js-snippets

Conversation

@sliemeobn

@sliemeobn sliemeobn commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

allow to ship JS code as the source for imports (in addition to ".global" and explicitly provided imports)

@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
// Modules/JSImportModule.mjs
export function moduleAdd(lhs, rhs) {
    return lhs + rhs;
}

export function renamedFunction() {
    return "renamed";
}

export const version = "1.0";

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)

@sliemeobn

Copy link
Copy Markdown
Contributor Author

closes #508

@krodak krodak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)"
    )
}

Comment thread Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift
```

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kateinoigakukun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@kateinoigakukun kateinoigakukun Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants