BridgeJS: import from external ECMAScript modules, and split snippet origins - #795
Conversation
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.
|
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: |
|
@sliemeobn Yeah, I agree that it makes more sense to have different How about |
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.
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 it maybe too late now, but I personally am not the biggest fan of wouldn't |
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.
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: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: .defaultnames 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 functionat 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#importsspecifiers pass through untouched..snippetkeeps full validation, since we own that file.Nothing is added to the generated
package.json. We have no version to write. Test builds runnpm installin 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_shimdependency.Static imports, which is already the precedent here.
Plugins/PackageToJS/Templates/platforms/node.jsstatically importsnode: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 likejsName: "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 inUnsupported-Features.md.Calling through a named import means
thisisundefinedinside the callee rather than the module namespace object. A CommonJS function that reaches sibling exports throughthiswill 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.swiftcovers three paths end to end under WebAssembly:basenamefromnode:path, constructingFilefrom@bjorn3/browser_wasi_shimand readingsize, and a default export viajsName: .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
JSImportFromround-trips including theglobalpackage-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
@JSClassrequired a module export that nothing looks up,jsName: niland the explicit.name(...)spelling were rejected despite being valid Swift, and the taggedfromform 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:Left out deliberately: teaching TS2Swift to emit module origins, so importing a package by pointing at its
.d.tsstill 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 passedswift test --package-path ./Plugins/PackageToJS: 40 passednpm run check:bridgejs-dts: cleanmake unittest: 34/34 suites, 194 XCTest plus 13 swift-testing tests, includingJSImportBareModuleTests🤖 Generated with Claude Code
https://claude.ai/code/session_01SiLM5XMR4V9iYC7gzHtnEa