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