Skip to content

Conversation

@1egoman
Copy link
Collaborator

@1egoman 1egoman commented Jan 14, 2026

Adds a subcommand for creating a npm module that wraps a native dll / dylib / so file for use in downstream package publishing processes.

Also adds flags to the main bindgen generate command to optionally consume 1 or many of these modules to get the location of the library binary.

The thinking on how this would be used is something like this:

  • A user would in their production ci workflow, after building the library for each platform, also now run the new publishing-scaffold-native-package subcommand to generate a wrapper package for their new built library. ie, something like uniffi-bindgen-node publishing-scaffold-native-package --package-name foo-linux path/to/liblibrary.so x86_64-unknown-linux-gnu. This needs to be run likely multiple times, once per platform specific package.
  • When generating the main bindgen code, they would pass --out-lib-path-module pointing to the new module's path / npm package name, with optionally keys included to filter by platform / arch and optionally a version included to add it as an optional dependency in the output package.json. ie, uniffi-bindgen-node generate /* ... */ --out-lib-path-module '{"module": "foo-linux", "arch": "x86", "os": "linux", "version": "v0.0.1"}'

After performing the above two steps and publishing all resulting packages, the main bindgen should be configured to look in the right place to find the built library in the correct platform specific package.

@1egoman 1egoman force-pushed the publish-native-package branch from 554286a to af7dd99 Compare January 15, 2026 03:12
@1egoman 1egoman marked this pull request as ready for review January 19, 2026 06:52
@1egoman 1egoman requested a review from ladvoc January 19, 2026 06:52
///
/// Only intended for use when publishing package to npm for distribution.
#[command(verbatim_doc_comment)]
PublishingScaffoldNativePackage(publishing_scaffold_native_package::PublishingScaffoldNativePackageSubcommandArgs),
Copy link
Collaborator Author

@1egoman 1egoman Jan 19, 2026

Choose a reason for hiding this comment

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

This subcommand name (publishing-scaffold-native-package) is too long, I could use some ideas for alternatives.

I could maybe drop the publishing prefix, but that makes it kinda confusing because the main generate command can by default make a package containing "native" libraries too, so I don't think that will work...

Comment on lines +89 to +108
/// The import path to a typescript module that exports a
/// function. This function, when called, should return an object containing a path key mapping
/// to an absolute path to the built lib.
///
/// For example, the below would be a compliant module:
/// > export default () => ({ path: "/path/to/my/built.dylib" });
///
/// This parameter can be included multiple times, and if so, the first module that can be
/// successfully imported will be queried to get the lib path. This can be used when building
/// a package intended to be published to production with a series of `optionalDependencies`,
/// each associated with a given os/arch to bundle native dependencies into a published
/// package. ie, `--out-lib-path-module @my/package --out-lib-path-module ./path/to/my/fallback.ts`
///
/// This parameter can also be set to a json object which allows for more complex scenarios
/// where one package will be only attempted if a given platform / arch match. ie,
/// `--out-lib-path-module '{"module": "@my/package", "version": "0.0.1", "platform": "darwin", "arch": "x86"}' --out-lib-path-module ./path/to/my/fallback.ts`
///
/// By default, this is is disabled in lieu of `out-lib-path-literal`.
#[arg(long, value_parser, default_value=None, conflicts_with="out_lib_path_literal")]
out_lib_path_module: Option<Vec<String>>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@ladvoc and I discussed this a bit in a 1:1. Both of us were thinking a config file could be warranted here given the complexity of the arg formatting in play here, but decided against it because it would make this command a fair bit harder to use in contexts where it is being run by an upstream tool (ie, like cargo-make) which invokes it programatically, since that config file would have to be templated.

That being said the json formatting thing here jacob wasn't a massive fan of either because the quote escaping gets quite annoying. We talked about maybe coming up with a bespoke DSL but neither of us could come up with something that wasn't super complex and which wasn't basically just reinventing a worse version of json.

Other ideas are welcome though, I could see some further evolution occurring here.

Comment on lines +184 to +189
#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_switch_grouping_works() {
Copy link
Collaborator Author

@1egoman 1egoman Jan 19, 2026

Choose a reason for hiding this comment

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

Note to self: there should be more tests here. Add some more in before merging.


pub fn generate_node_bindings(
ci: &ComponentInterface,
sys_ts_main_file_name: &str,
Copy link
Contributor

Choose a reason for hiding this comment

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

nitpick: This is getting big enough that defining a struct for it (e.g., GenerateNodeOptions) might be a good idea.

args.out_dir.clone().join("src").join(lib_source_filename),
).context(format!("Error copying {lib_source_filename} into package"))?;

let package_json = json!({
Copy link

Choose a reason for hiding this comment

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

question: is there a reason behind having the main package in a template format and the native ones defined in code like this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Mostly because they were fairly simple and I was having a difficult time figuring out how to organize the templates in such a way to keep the native packaging related ones separate.

I don't have a strong opinion though, @ladvoc do you have one / want to break the tie?

Copy link

Choose a reason for hiding this comment

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

was just a curiousity question from my side, just keep it as is!

args.lib_triple,
lib_source_filename,
)),
("index.d.ts", format!(
Copy link

@lukasIO lukasIO Jan 21, 2026

Choose a reason for hiding this comment

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

nit: for correct TS support this should be split into d.mts and d.cts

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah, yes. I think I have made the necessary modifications in 3957d04, but you are definitely more of an expert on this than me, so if you see something still incorrect let me know.

lib_source_filename,
)),
("index.mjs", format!(
r#"import {{ join, dirname }} from "path"; import {{ fileURLToPath }} from "url"; export default () => ({{ triple: "{}", path: join(dirname(fileURLToPath(import.meta.url)), "{}") }});"#,
Copy link

Choose a reason for hiding this comment

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

I haven't really thought this through so curious about your take when deciding wether to have this wrapper JS code live in the native package rather than in the "main" one?

Copy link
Collaborator Author

@1egoman 1egoman Jan 21, 2026

Choose a reason for hiding this comment

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

I think there isn't really a way to do this where there isn't code in the native package unless you assume the path to the included dlib binary in the "main" package. I didn't really want to do that because it means you can't have this nice standardized interface that allows you to swap out the native package for any module which exports the right thing.

It definitely is an open question how useful this standardized interface actually is, and I think only time will tell. But I will say I see some value to it in "unusual" situations. For example, maybe you want to do the regular thing for all but one native package, and then for one native package you want to override it for something custom that injects some custom code which gets the dlib somewhere special, or like downloads artifacts on first use, etc.

Also it is worth noting that this differs a bit from the way napi works, but also on the other hand, napi is dealing with *.node files which can be directly imported, so in their case the native packages that are generate literally have the *.node file as the package.json main and there isn't any js in the package at all. But because our dlib binaries aren't directly importable that strategy won't work.

Copy link

Choose a reason for hiding this comment

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

Thanks for the clarification, my question was basically about the last paragraph, paraphrased "why don't we do it like napi".

Given the constraint you mentioned, this all makes sense to me

Copy link

@lukasIO lukasIO left a comment

Choose a reason for hiding this comment

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

lgtm

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.

4 participants