Skip to content

BridgeJS: import from external ECMAScript modules, and split snippet origins - #795

Merged
kateinoigakukun merged 4 commits into
mainfrom
yt/bridge-js-bare-module-specifiers
Aug 3, 2026
Merged

BridgeJS: import from external ECMAScript modules, and split snippet origins#795
kateinoigakukun merged 4 commits into
mainfrom
yt/bridge-js-bare-module-specifiers

Conversation

@kateinoigakukun

@kateinoigakukun kateinoigakukun commented Jul 30, 2026

Copy link
Copy Markdown
Member

BridgeJS can only import JavaScript that lives inside your own Swift target. This adds modules you did not write: Node builtins and installed npm packages.

@JSFunction(jsName: "basename", from: .module("node:path"))
func basename(_ path: String) throws(JSException) -> String

@JSClass(jsName: "File", from: .module("@bjorn3/browser_wasi_shim"))
struct WasiFile {
    @JSFunction init(_ data: JSObject) throws(JSException)
    @JSGetter var size: Int64
}

Following review, the two kinds of origin are separate cases rather than one overloaded .module. from: .snippet("/my-file.js") names a JavaScript file you ship with the target and keeps its strict validation — rooted path, .js/.mjs, file must exist. from: .module("node:path") names an external module and is passed to the JavaScript resolver verbatim. Each mistaken form points at the other:

error: '/Modules/utils.mjs' looks like a file in this target. Use
       'from: .snippet("/Modules/utils.mjs")' for a JavaScript file you ship
       with the target, and 'from: .module(...)' for an external module.

This renames the existing .module("/my-file.js") spelling to .snippet("/my-file.js"). Nothing has shipped with .module, so no released API changes.

Two smaller additions ride along. jsName: .default names a module's default export, which is how many npm packages expose their main value. Reaching it used to require a local re-export file.

Generated glue also switches from a namespace object plus property lookup to named ECMAScript imports. A misspelled export name is now reported by the engine at module-link time, rather than surfacing as undefined is not a function at call time. Existing local-file users get that improvement too.

Design notes

No build-time resolution check for .module. Resolution is host-defined. A bundler alias, an import map, or Deno will resolve specifiers that Node rejects, so any check we wrote would reject working setups. Validation is limited to rejecting the empty string, relative specifiers, and rooted paths. URL and #imports specifiers pass through untouched. .snippet keeps full validation, since we own that file.

Nothing is added to the generated package.json. We have no version to write. Test builds run npm install in the output directory, so injecting "*" would make a Swift build download unpinned packages. Installing the dependency stays with the consumer, same as the generated package's existing @bjorn3/browser_wasi_shim dependency.

Static imports, which is already the precedent here. Plugins/PackageToJS/Templates/platforms/node.js statically imports node:url, node:worker_threads, and an npm package today.

The skeleton tags both origins. "from" is "global", {"kind":"snippet","path":"..."}, or {"kind":"module","specifier":"..."}, so the JSON mirrors the Swift cases. Snippets were previously written as a plain path string, which meant a reader had to know that a /-prefixed string was a snippet and any other string was not, and it let a non-rooted snippet path encode into a shape that could not decode. Nothing is tagged with either form yet, so no consumer breaks; the churn is 11 entries across three checked-in files.

Named imports fall back per origin. If any export name for one specifier is not a valid JavaScript identifier, that specifier reverts to import * as. This avoids depending on ES2022 string-literal import names, which matters because TS2Swift emits names like jsName: "property-with-dashes". A specifier with no member lookups at all also keeps the namespace form, because a named import is a link-time requirement and importing a name nothing references would fail the module load.

Consequences worth knowing

Importing a node: builtin makes the generated package Node-only. An npm package must be resolvable from the output directory or aliased by the consumer's bundler. A typo in a specifier is reported by your JavaScript toolchain when it loads the module; Swift compilation still succeeds. All three are documented in Unsupported-Features.md.

Calling through a named import means this is undefined inside the callee rather than the module namespace object. A CommonJS function that reaches sibling exports through this will fail; import the default export and call the member through it in that case.

One non-obvious coupling: a bare specifier is shared across Swift modules, so its member names form a union. A non-identifier export name used by one target degrades the shared import for every other target using that specifier. That follows from "one specifier, one import statement" and is pinned by a test.

Testing

Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift covers three paths end to end under WebAssembly: basename from node:path, constructing File from @bjorn3/browser_wasi_shim and reading size, and a default export via jsName: .default. That npm package adds no new dependency and resolves in both build modes.

Also added: snapshot inputs for the named-import and fallback paths, and JSImportFrom round-trips including the global package-name collision. Eight registry tests link two Swift modules to cover cross-module dedupe, per-module local copies, deterministic ordering, specifier escaping, and reserved-word export names. One packaging test checks that a bare specifier does not suppress copying of local modules beside it.

Review caught three defects, all fixed with regression tests in the second commit: a wrapper-only @JSClass required a module export that nothing looks up, jsName: nil and the explicit .name(...) spelling were rejected despite being valid Swift, and the tagged from form skipped the validation its plain-string counterpart applies.

Reserved-word export names were checked against Node rather than only asserted as text, since import { class as X } had to be legal for that path to work:

export { c as class, i as import, d as default };   // pkg.mjs
import { class as C, import as I, default as D } from "./pkg.mjs";
$ node main.mjs → class -> 1 import -> 2 default -> 3

Left out deliberately: teaching TS2Swift to emit module origins, so importing a package by pointing at its .d.ts still needs hand-written macro declarations. That is the obvious follow-up and is noted in the docs.

Verification

  • swift test --package-path ./Plugins/BridgeJS: 185 passed
  • swift test --package-path ./Plugins/PackageToJS: 40 passed
  • npm run check:bridgejs-dts: clean
  • make unittest: 34/34 suites, 194 XCTest plus 13 swift-testing tests, including JSImportBareModuleTests

🤖 Generated with Claude Code

https://claude.ai/code/session_01SiLM5XMR4V9iYC7gzHtnEa

Extend `from: .module(...)` to accept bare specifiers like `node:path` or an npm package,
add `jsName: .default` for default exports, and emit named imports so a wrong export name
now fails at module-link time instead of at call time.
Do not require a module export for a wrapper-only `@JSClass`, since a named import is a
link-time requirement and nothing looks that name up; accept `jsName: nil` and the explicit
`.name(...)` spelling; and validate the tagged `from` form like the plain-string form.
@sliemeobn

Copy link
Copy Markdown
Contributor

I like the idea, but I am not sure I like "losing" the diagnostic guidance to use "/my-file.js" in there - now "anything goes" and there is no way to find out until things don't work at runtime.

how about we use separate cases? eg: from: .import("node:path") vs from: .module("/my-file.js")
or, since we have not tagged a version with .module we could still find another name for it maybe?

@kateinoigakukun

Copy link
Copy Markdown
Member Author

@sliemeobn Yeah, I agree that it makes more sense to have different from: specifiers for snippet and bare ES module import. Given that both use ESM import statement, from: .import("node:path") sounds a little bit weird to me.

How about from: .module("node:path") and from: .snippet("/my-file.js")?

@kateinoigakukun
kateinoigakukun marked this pull request as ready for review August 2, 2026 20:03
Use `from: .snippet("/my-file.js")` for a JavaScript file shipped with the Swift target and
`from: .module("node:path")` for an external module, so each keeps its own validation and
each mistaken form points at the other. The skeleton encoding is unchanged.
@kateinoigakukun kateinoigakukun changed the title BridgeJS: support importing from external ECMAScript modules BridgeJS: import from external ECMAScript modules, and split snippet origins Aug 3, 2026
Encode `.snippet` as `{"kind":"snippet","path":...}` alongside the existing tagged module
form, so the JSON mirrors the Swift cases and a snippet path can no longer encode into a
shape that fails to decode.
@kateinoigakukun
kateinoigakukun enabled auto-merge (squash) August 3, 2026 12:12
@kateinoigakukun
kateinoigakukun merged commit e53155d into main Aug 3, 2026
16 checks passed
@kateinoigakukun
kateinoigakukun deleted the yt/bridge-js-bare-module-specifiers branch August 3, 2026 12:14
@sliemeobn

Copy link
Copy Markdown
Contributor

@kateinoigakukun it maybe too late now, but I personally am not the biggest fan of .snippet("..."). what even is "a snippet"?

wouldn't from: .file("/my-file.js") read more cleanly?

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.

2 participants