From 8d86a8b8ea58dbddb98ac78884238e752b20a815 Mon Sep 17 00:00:00 2001 From: "Leilei Zhang (from Dev Box)" Date: Tue, 11 Aug 2026 13:27:42 +0800 Subject: [PATCH 1/3] Optimize generated binding layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 84688dea-d965-4933-a2d9-41f8f6ad2f2e --- README.md | 52 +++ tools/dynwinrt-codegen/npm/README.md | 24 ++ .../src/codegen/winrt/javascript/bundle.rs | 365 ++++++++++++++++ .../src/codegen/winrt/javascript/generator.rs | 31 +- .../src/codegen/winrt/javascript/ir.rs | 13 + .../src/codegen/winrt/javascript/mod.rs | 1 + .../codegen/winrt/javascript/project/mod.rs | 184 +++++++- .../winrt/javascript/render/javascript/mod.rs | 63 ++- tools/dynwinrt-codegen/src/main.rs | 395 +++++++++++++++++- .../tests/binding_bundle_test.rs | 353 ++++++++++++++++ .../tests/shared_interface_members_test.rs | 366 ++++++++++++++++ 11 files changed, 1821 insertions(+), 26 deletions(-) create mode 100644 tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs create mode 100644 tools/dynwinrt-codegen/tests/binding_bundle_test.rs create mode 100644 tools/dynwinrt-codegen/tests/shared_interface_members_test.rs diff --git a/README.md b/README.md index a37b702c..45b45bb3 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,62 @@ For deployment, see | `--lang LANG` | No | `js` (default, emits `.js` + `.d.ts`) or `py` (emits `.py` + `.pyi` and `py.typed`) | | `--no-pyi` | No | With `--lang py`, emit implementation files without type stubs | | `--output DIR` | No | Output directory (default `./generated`) | +| `--shared-interface-members` | No | JS opt-in: reuse shared required-interface prototype descriptors instead of duplicating inherited member bodies in every concrete class | | `--dry-run` | No | Validate input, don't write files | For each WinRT class the codegen emits a typed wrapper, factory, interface registration, async + progress support, generic collections, structs, enums, delegates, and an `index.js` / `index.d.ts` that re-exports every emitted symbol. +### Binding layout optimization + +Large WinUI projections can opt into shared inherited-interface implementations +without changing concrete class declarations or member names: + +```powershell +dynwinrt-codegen generate ` + --winmd-list .winapp\winmds.txt ` + --class-name Microsoft.UI.Xaml.Controls.Button,Microsoft.UI.Xaml.Controls.TextBlock ` + --output .winapp\bindings ` + --shared-interface-members +``` + +The generated concrete prototypes receive the same method/accessor descriptors +from standalone shared interface prototypes. Overloaded or conflicting members +remain class-local, raw interface wrapper classes remain available, and the +option does not change existing `.d.ts` files. + +After normal JS generation completes, configure one or more first-screen +CommonJS bundles with the `bundle` subcommand: + +```powershell +dynwinrt-codegen bundle ` + --output .winapp\bindings ` + --bundle first-screen=Application,Window,Button,lifetime +``` + +`first-screen.js` contains those roots and their generated relative-require +closure. The root `index.js` redirects matching named exports to the bundle; +unconfigured exports keep their normal per-type lazy files. Bundled per-type +`.js` paths become redirect shims into the bundle's canonical CommonJS cache, +while every `.d.ts` path remains unchanged. Root CommonJS, root ESM, and deep +imports therefore share constructor and lifetime identity. External +`@microsoft/dynwinrt` requires stay external and cycles use CommonJS-style +partial module exports. When multiple bundles share a dependency, an explicitly +configured root owns it; otherwise the shared module stays unbundled so every +bundle resolves the same CommonJS instance. + +Run `bundle` only against a freshly generated, unbundled output directory. The +command is intentionally single-use: it fails if any configured root is +missing or if bundle artifacts/redirect shims already exist, rather than +silently embedding stale sources. Generate into a new or cleaned output +directory (or copy a fresh generation directory) before changing bundle roots +or rebuilding a bundle; in-place generation over a bundled tree is rejected. + +Focused validation: + +```powershell +cargo test -p dynwinrt-codegen --test shared_interface_members_test --test binding_bundle_test +``` + ## Local development — fix import paths in generated files Generated files import from `'@microsoft/dynwinrt'`. When iterating against a locally-built runtime, rewrite imports to the relative path: diff --git a/tools/dynwinrt-codegen/npm/README.md b/tools/dynwinrt-codegen/npm/README.md index d4e74b3a..09bf718f 100644 --- a/tools/dynwinrt-codegen/npm/README.md +++ b/tools/dynwinrt-codegen/npm/README.md @@ -56,8 +56,32 @@ npx dynwinrt-codegen generate \ | `--ref PATH` | Additional `.winmd` files for type resolution only (no code emitted) | | `--lang LANG` | `js` (default, emits `.js` + `.d.ts`) or `py` (Python) | | `--output DIR` | Output directory (default `./generated`) | +| `--shared-interface-members` | JS opt-in that shares inherited interface member descriptors across concrete classes | | `--dry-run` | Validate input, don't write files | +### First-screen layout + +```powershell +npx dynwinrt-codegen bundle ` + --output .winapp\bindings ` + --bundle first-screen=Application,Window,Button,lifetime +``` + +The opt-in shared-member mode keeps the concrete JS and declaration API intact, +including raw interface wrappers and overload dispatch. Each bundle embeds the +configured generated modules plus their relative dependency closure in one +CommonJS file, preserves external runtime requires and CommonJS cycle caching, +and emits a matching `.d.ts` re-export file. Bundled per-type paths are +canonical redirect shims, so root/deep CommonJS and ESM imports share +constructor and projection-lifetime identity. Dependencies shared by multiple +bundles stay unbundled unless one bundle explicitly configures them as a root, +so all bundles resolve one CommonJS instance. + +Run `bundle` only on a freshly generated, unbundled output directory. It fails +when configured roots are missing or bundle artifacts/shims already exist; +generate into a new or cleaned directory (or copy fresh output) before changing +roots or rebundling. In-place generation over a bundled tree is rejected. + ## What gets generated For each WinRT class, the codegen emits: diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs new file mode 100644 index 00000000..efe6433b --- /dev/null +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs @@ -0,0 +1,365 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reproducible CommonJS bundles for configured generated binding entry modules. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindingBundleSpec { + pub name: String, + pub modules: Vec, +} + +pub struct GeneratedBindingBundle { + pub js: String, + pub dts: String, + pub exports: BTreeSet, + pub module_count: usize, + pub bundled_source_bytes: usize, + pub modules: BTreeSet, +} + +pub fn parse_binding_bundle_spec(value: &str) -> Result { + let (name, modules) = value + .split_once('=') + .ok_or_else(|| format!("Invalid --bundle `{value}`; expected NAME=MODULE[,MODULE...]"))?; + let name = name.trim(); + validate_binding_file_stem(name, "bundle name")?; + if matches!(name, "index" | "index.proxy" | "index.getter" | "lifetime") { + return Err(format!("Reserved bundle name `{name}`")); + } + + let mut modules = modules + .split(',') + .map(str::trim) + .filter(|module| !module.is_empty()) + .map(|module| module.strip_suffix(".js").unwrap_or(module).to_string()) + .collect::>(); + if modules.is_empty() { + return Err(format!( + "Bundle `{name}` must configure at least one module" + )); + } + for module in &modules { + validate_binding_file_stem(module, "bundle module")?; + } + modules.sort(); + modules.dedup(); + Ok(BindingBundleSpec { + name: name.to_string(), + modules, + }) +} + +pub fn generate_binding_bundle( + output_dir: &Path, + spec: &BindingBundleSpec, +) -> Result { + let modules = collect_binding_bundle_modules(output_dir, spec)?; + generate_binding_bundle_with_modules(output_dir, spec, &modules) +} + +pub fn collect_binding_bundle_modules( + output_dir: &Path, + spec: &BindingBundleSpec, +) -> Result, String> { + validate_binding_file_stem(&spec.name, "bundle name")?; + for module in &spec.modules { + validate_binding_file_stem(module, "bundle module")?; + } + + let mut modules = BTreeSet::new(); + let mut pending = VecDeque::from(spec.modules.clone()); + while let Some(module) = pending.pop_front() { + if modules.contains(&module) { + continue; + } + let path = binding_output_file_path(output_dir, &module, "js", "bundle module")?; + let source = fs::read_to_string(&path).map_err(|error| { + format!( + "Bundle `{}` references missing generated module {}: {error}", + spec.name, + path.display() + ) + })?; + for dependency in collect_relative_requires(&source) { + let dependency_path = + binding_output_file_path(output_dir, &dependency, "js", "bundle dependency")?; + if !dependency_path.is_file() { + return Err(format!( + "Bundle `{}` dependency closure is incomplete: generated module `{module}` \ + requires missing generated sibling `{dependency}` at {}", + spec.name, + dependency_path.display(), + )); + } + if !modules.contains(&dependency) && !pending.contains(&dependency) { + pending.push_back(dependency); + } + } + modules.insert(module); + } + Ok(modules) +} + +pub fn generate_binding_bundle_with_modules( + output_dir: &Path, + spec: &BindingBundleSpec, + included_modules: &BTreeSet, +) -> Result { + validate_binding_file_stem(&spec.name, "bundle name")?; + for module in &spec.modules { + validate_binding_file_stem(module, "bundle module")?; + if !included_modules.contains(module) { + return Err(format!( + "Bundle `{}` does not own configured root `{module}`", + spec.name + )); + } + } + + let mut modules = BTreeMap::::new(); + for module in included_modules { + let path = binding_output_file_path(output_dir, module, "js", "bundle module")?; + let source = fs::read_to_string(&path).map_err(|error| { + format!( + "Bundle `{}` references missing generated module {}: {error}", + spec.name, + path.display() + ) + })?; + modules.insert(module.clone(), source); + } + + let mut export_owners = BTreeMap::::new(); + for (module, source) in &modules { + for export in collect_cjs_exports(source) { + if export == *module { + export_owners.insert(export, module.clone()); + } + } + } + for module in &spec.modules { + let source = modules + .get(module) + .ok_or_else(|| format!("Bundle `{}` did not load `{module}`", spec.name))?; + for export in collect_cjs_exports(source) { + export_owners + .entry(export) + .or_insert_with(|| module.clone()); + } + } + for (module, source) in &modules { + for export in collect_cjs_exports(source) { + export_owners + .entry(export) + .or_insert_with(|| module.clone()); + } + } + + let bundled_source_bytes = modules.values().map(String::len).sum(); + let mut js = String::new(); + js.push_str("// Generated by dynwinrt-codegen — do not edit\n"); + js.push_str(&format!( + "// Bundle `{}`: {} configured roots, {} embedded modules\n", + spec.name, + spec.modules.len(), + modules.len() + )); + js.push_str("const __nativeRequire = require;\n"); + js.push_str("const __modules = Object.create(null);\n"); + for (module, source) in &modules { + js.push_str(&format!( + "__modules['./{module}.js'] = (module, exports, require) => {{\n{source}\n}};\n" + )); + } + js.push_str( + "const __cache = Object.create(null);\n\ +const __normalize = (request, parent) => {\n\ + if (!request.startsWith('.')) return null;\n\ + const parts = parent.split('/');\n\ + parts.pop();\n\ + for (const part of request.split('/')) {\n\ + if (part === '' || part === '.') continue;\n\ + if (part === '..') parts.pop();\n\ + else parts.push(part);\n\ + }\n\ + return parts.join('/');\n\ +};\n\ +const __load = (id) => {\n\ + const cached = __cache[id];\n\ + if (cached !== undefined) return cached.exports;\n\ + const factory = __modules[id];\n\ + if (factory === undefined) return __nativeRequire(id);\n\ + const module = { exports: {} };\n\ + __cache[id] = module;\n\ + factory(module, module.exports, (request) => {\n\ + const resolved = __normalize(request, id);\n\ + return resolved !== null && __modules[resolved] !== undefined\n\ + ? __load(resolved)\n\ + : __nativeRequire(request);\n\ + });\n\ + return module.exports;\n\ +};\n", + ); + js.push_str( + "Object.defineProperty(exports, '__dynwinrtLoadBundledModule', { value: __load });\n", + ); + for (export, owner) in &export_owners { + js.push_str(&format!( + "exports.{export} = undefined;\nObject.defineProperty(exports, '{export}', {{ enumerable: true, configurable: true, get: () => __load('./{owner}.js').{export} }});\n", + )); + } + + let mut dts = String::from("// Generated by dynwinrt-codegen — do not edit\n"); + for module in modules.keys() { + let names = export_owners + .iter() + .filter_map(|(name, owner)| (owner == module).then_some(name.as_str())) + .collect::>(); + if !names.is_empty() { + dts.push_str(&format!( + "export {{ {} }} from './{module}.js';\n", + names.join(", ") + )); + } + } + + Ok(GeneratedBindingBundle { + js, + dts, + exports: export_owners.into_keys().collect(), + module_count: modules.len(), + bundled_source_bytes, + modules: modules.into_keys().collect(), + }) +} + +pub fn binding_bundle_redirect(bundle_name: &str, module_name: &str) -> String { + format!( + "// Generated by dynwinrt-codegen — do not edit\nmodule.exports = require('./{bundle_name}.js').__dynwinrtLoadBundledModule('./{module_name}.js');\n", + ) +} + +pub fn validate_binding_file_stem(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) + { + return Err(format!( + "Invalid {label} `{value}`; use only ASCII letters, digits, `_`, or `-`" + )); + } + Ok(()) +} + +pub fn binding_output_file_path( + output_dir: &Path, + stem: &str, + extension: &str, + label: &str, +) -> Result { + validate_binding_file_stem(stem, label)?; + let canonical_output = fs::canonicalize(output_dir).map_err(|error| { + format!( + "Failed to resolve binding output directory {}: {error}", + output_dir.display() + ) + })?; + let file_name = format!("{stem}.{extension}"); + let path = output_dir.join(&file_name); + let resolved = match fs::symlink_metadata(&path) { + Ok(_) => fs::canonicalize(&path) + .map_err(|error| format!("Failed to resolve {}: {error}", path.display()))?, + Err(error) if error.kind() == ErrorKind::NotFound => canonical_output.join(&file_name), + Err(error) => { + return Err(format!("Failed to inspect {}: {error}", path.display())); + } + }; + if resolved.parent() != Some(canonical_output.as_path()) { + return Err(format!( + "Refusing {label} `{stem}` because {} resolves outside binding output {}", + path.display(), + canonical_output.display() + )); + } + Ok(path) +} + +fn collect_relative_requires(source: &str) -> BTreeSet { + collect_require_sources(source) + .into_iter() + .filter_map(|request| { + let module = request.strip_prefix("./")?.strip_suffix(".js")?; + (!module.is_empty() && !module.contains(['/', '\\'])).then(|| module.to_string()) + }) + .collect() +} + +fn collect_require_sources(source: &str) -> Vec { + let mut requests = Vec::new(); + let mut rest = source; + while let Some(index) = rest.find("require(") { + rest = &rest[index + "require(".len()..]; + let trimmed = rest.trim_start(); + let Some(quote) = trimmed + .chars() + .next() + .filter(|quote| matches!(quote, '\'' | '"')) + else { + continue; + }; + let quoted = &trimmed[quote.len_utf8()..]; + let Some(end) = quoted.find(quote) else { + break; + }; + requests.push(quoted[..end].to_string()); + rest = "ed[end + quote.len_utf8()..]; + } + requests +} + +fn collect_cjs_exports(source: &str) -> BTreeSet { + source + .lines() + .filter_map(|line| { + let rest = line.trim_start().strip_prefix("exports.")?; + let name = rest + .chars() + .take_while(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '$') + }) + .collect::(); + (!name.is_empty() && rest[name.len()..].trim_start().starts_with('=')).then_some(name) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundle_spec_is_sorted_and_deduplicated() { + let spec = parse_binding_bundle_spec("first-screen=B,A,B.js").unwrap(); + assert_eq!(spec.name, "first-screen"); + assert_eq!(spec.modules, ["A", "B"]); + } + + #[test] + fn require_scanner_keeps_only_flat_generated_siblings() { + let source = "\ +const a = require('./A.js');\n\ +const runtime = require('@microsoft/dynwinrt');\n\ +const nested = require('./nested/B.js');\n"; + assert_eq!( + collect_relative_requires(source), + BTreeSet::from(["A".to_string()]) + ); + } +} diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs index 86012582..eb277246 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use crate::meta::{ClassMeta, InterfaceMeta}; use crate::types::TypeMeta; @@ -171,6 +171,15 @@ pub fn esm_index_to_cjs_lazy(esm_index: &str) -> String { /// `index.mjs` ESM barrel. Use `esm_index_to_cjs_lazy` when a lexer-visible /// CJS barrel is required. pub fn esm_index_to_cjs_getter(esm_index: &str) -> String { + esm_index_to_cjs_getter_with_overrides(esm_index, &BTreeMap::new()) +} + +/// ESM index → CJS getter barrel, redirecting selected exports to generated +/// bundle modules while leaving every other export on its normal per-type file. +pub fn esm_index_to_cjs_getter_with_overrides( + esm_index: &str, + module_overrides: &BTreeMap, +) -> String { let mut out = String::new(); out.push_str("// Generated by dynwinrt-codegen \u{2014} do not edit\n"); out.push_str( @@ -194,6 +203,10 @@ pub fn esm_index_to_cjs_getter(esm_index: &str) -> String { None => continue, }; for name in names { + let module = module_overrides + .get(&name) + .map(String::as_str) + .unwrap_or(&module); out.push_str(&format!( "__exportLazy('{name}', './{module}.js');\n", name = name, @@ -213,6 +226,22 @@ pub fn esm_index_to_cjs_getter(esm_index: &str) -> String { out } +#[cfg(test)] +mod bundle_override_tests { + use super::*; + + #[test] + fn getter_barrel_redirects_only_configured_exports() { + let index = "\ +export { Button } from './Button.js';\n\ +export { TextBlock } from './TextBlock.js';\n"; + let overrides = BTreeMap::from([("Button".into(), "first-screen".into())]); + let output = esm_index_to_cjs_getter_with_overrides(index, &overrides); + assert!(output.contains("__exportLazy('Button', './first-screen.js');")); + assert!(output.contains("__exportLazy('TextBlock', './TextBlock.js');")); + } +} + /// Emit the ESM barrel. Re-exports are *eager* — the barrel loads every /// sibling transitively; consumers who care about startup should use the /// subpath exports from the generated `package.json` instead. diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs index 919a0ce5..ddb8a48a 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs @@ -257,18 +257,31 @@ pub struct ProjectedClass { pub doc: Option, pub members: Vec, pub required_ifaces: Vec, + /// Required-interface members whose implementation descriptors are copied + /// from a shared standalone interface prototype. + pub shared_interface_members: Vec, /// Static factory/static interface cache field declarations (JS only) pub static_cache_fields: Vec, /// Static factory/static interface accessor methods (JS only) pub static_accessors: Vec, } +pub struct ProjectedSharedInterfaceMembers { + pub interface_name: String, + /// Projection-level member keys used to suppress duplicate class bodies. + pub member_keys: Vec, + /// JavaScript property-key expressions copied from the interface prototype. + pub descriptor_keys: Vec, +} + pub struct ProjectedIface { pub name: String, pub doc: Option, pub iid_const: Option, pub has_static_from: bool, pub has_parameterized_cast: bool, + /// The interface prototype may be reused by concrete runtime classes. + pub shared_member_source: bool, pub members: Vec, pub is_delegate: bool, } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs index 5aef747f..b6641a57 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod bundle; pub(crate) mod docs; pub mod generator; pub mod ir; diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs index edce23f4..3e385fe2 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs @@ -23,6 +23,7 @@ use crate::types::{TypeKind, TypeMeta}; thread_local! { static RUNTIME_IMPORT_NAME: RefCell = RefCell::new("@microsoft/dynwinrt".into()); + static SHARED_INTERFACE_MEMBERS: RefCell = const { RefCell::new(false) }; } /// Set the runtime package import name used in generated JS/TS files. @@ -35,6 +36,16 @@ pub fn get_import_name() -> String { RUNTIME_IMPORT_NAME.with(|n| n.borrow().clone()) } +/// Opt into reusing standalone interface prototype descriptors from concrete +/// runtime classes instead of rendering inherited member bodies repeatedly. +pub fn set_shared_interface_members(enabled: bool) { + SHARED_INTERFACE_MEMBERS.with(|value| *value.borrow_mut() = enabled); +} + +fn shared_interface_members_enabled() -> bool { + SHARED_INTERFACE_MEMBERS.with(|value| *value.borrow()) +} + use crate::codegen::winrt::shared::imports::{ NO_DEFERRED, collect_iface_type_imports, collect_type_imports, collect_used_generics_from_class, collect_used_generics_from_methods, fill_array_output_index, @@ -878,6 +889,9 @@ pub fn project_class( // Required interface inline wrappers let mut required_ifaces = Vec::new(); + let mut shared_member_candidates: Vec<(String, String, Vec)> = Vec::new(); + let mut conflicting_shared_members = HashSet::new(); + let share_interface_members = shared_interface_members_enabled(); // Track names already on the main class to avoid conflicts let mut main_member_names: HashSet = members .iter() @@ -987,9 +1001,25 @@ pub fn project_class( let sig_key = format!("{}#{}", name, pm.params.len()); if main_member_names.insert(sig_key) { members.push(member.clone()); + if share_interface_members && is_imported { + record_shared_member_candidate( + &mut shared_member_candidates, + &mut conflicting_shared_members, + req_iface, + member, + ); + } } } else if main_member_names.insert(name) { members.push(member.clone()); + if share_interface_members && is_imported { + record_shared_member_candidate( + &mut shared_member_candidates, + &mut conflicting_shared_members, + req_iface, + member, + ); + } } } @@ -1012,6 +1042,45 @@ pub fn project_class( // Merge overloaded method names: rename `foo2`, `foo3` to `foo` when `foo` exists. // Must happen after flatten so required-interface methods are included. merge_overload_names(&mut members); + let mut final_member_counts = HashMap::new(); + for member in &members { + if let Some(member_key) = shared_member_key(member) { + *final_member_counts.entry(member_key).or_insert(0usize) += 1; + } + } + conflicting_shared_members.extend( + final_member_counts + .into_iter() + .filter_map(|(member_key, count)| (count > 1).then_some(member_key)), + ); + + let mut shared_interface_members: Vec = Vec::new(); + if share_interface_members { + for (member_key, interface_name, descriptor_keys) in shared_member_candidates { + if conflicting_shared_members.contains(&member_key) { + continue; + } + let group = shared_interface_members + .iter_mut() + .find(|group| group.interface_name == interface_name); + if let Some(group) = group { + if !group.member_keys.contains(&member_key) { + group.member_keys.push(member_key); + } + for descriptor_key in descriptor_keys { + if !group.descriptor_keys.contains(&descriptor_key) { + group.descriptor_keys.push(descriptor_key); + } + } + } else { + shared_interface_members.push(ProjectedSharedInterfaceMembers { + interface_name, + member_keys: vec![member_key], + descriptor_keys, + }); + } + } + } // Check if _unwrap is used let needs_unwrap = check_needs_unwrap(&members, &required_ifaces); @@ -1029,6 +1098,7 @@ pub fn project_class( doc, members, required_ifaces, + shared_interface_members, static_cache_fields, static_accessors, }], @@ -1173,6 +1243,12 @@ pub fn project_interface( // Members let iface_var = format!("_{}", iface.name); + let shared_member_source = shared_interface_members_enabled() && !iface.iid.is_empty(); + let obj_expr = if shared_member_source { + "__interfaceValue(this)" + } else { + "this._obj" + }; let mut members = Vec::new(); for method in &iface.methods { if should_skip_raw_collection_method(iface, &method.name) { @@ -1180,7 +1256,7 @@ pub fn project_interface( } if let Some(m) = project_instance_method( &iface_var, - "this._obj", + obj_expr, method, known_types, &delegate_names, @@ -1193,7 +1269,7 @@ pub fn project_interface( } // Collection helpers - project_collection_helpers(iface, known_types, &mut members, &mut imports, "this._obj"); + project_collection_helpers(iface, known_types, &mut members, &mut imports, obj_expr); // Static create() for IVector / IMap project_collection_create(iface, known_types, &mut members, &mut imports); @@ -1269,6 +1345,7 @@ pub fn project_interface( iid_const: None, // already in file-level iid_consts has_static_from: !iface.iid.is_empty(), has_parameterized_cast, + shared_member_source, members, is_delegate: false, }], @@ -1411,6 +1488,99 @@ pub fn project_delegate( // Utility helpers // ====================================================================== +fn record_shared_member_candidate( + candidates: &mut Vec<(String, String, Vec)>, + conflicts: &mut HashSet, + interface: &InterfaceMeta, + member: &ProjectedMember, +) { + let Some(member_key) = shared_member_key(member) else { + return; + }; + if matches!( + member, + ProjectedMember::Method(ProjectedMethod { + overload_of: Some(_), + .. + }) + ) { + conflicts.insert(member_key); + return; + } + let descriptor_keys = shared_member_descriptor_keys(member); + if descriptor_keys.is_empty() { + return; + } + if let Some((_, existing_interface, existing_descriptors)) = candidates + .iter_mut() + .find(|(existing_key, _, _)| existing_key == &member_key) + { + if existing_interface != &interface.name || matches!(member, ProjectedMember::Method(_)) { + conflicts.insert(member_key); + } else { + for descriptor_key in descriptor_keys { + if !existing_descriptors.contains(&descriptor_key) { + existing_descriptors.push(descriptor_key); + } + } + } + return; + } + candidates.push((member_key, interface.name.clone(), descriptor_keys)); +} + +fn shared_member_key(member: &ProjectedMember) -> Option { + match member { + ProjectedMember::Method(method) => { + let has_numeric_suffix = method + .name + .chars() + .last() + .is_some_and(|character| character.is_ascii_digit()); + (!has_numeric_suffix).then(|| method.name.clone()) + } + ProjectedMember::Property(property) => Some(property.name.clone()), + ProjectedMember::Event(event) if !event.subscribe_name.is_empty() => { + Some(event.subscribe_name.clone()) + } + ProjectedMember::Symbol(symbol) => Some(symbol_dedup_key(&symbol.kind)), + _ => None, + } +} + +fn shared_member_descriptor_keys(member: &ProjectedMember) -> Vec { + let quoted = |name: &str| format!("'{name}'"); + match member { + ProjectedMember::Method(method) => vec![quoted(&method.name)], + ProjectedMember::Property(property) => vec![quoted(&property.name)], + ProjectedMember::Event(event) if !event.subscribe_name.is_empty() => { + let event_name = event + .subscribe_name + .strip_prefix("on") + .unwrap_or(&event.subscribe_name); + let mut names = vec![ + quoted(&event.subscribe_name), + quoted(&format!("once{event_name}")), + ]; + if event.remove_vtable_index.is_some() { + names.push(quoted(&format!("off{event_name}"))); + } + names + } + ProjectedMember::Symbol(symbol) => vec![match &symbol.kind { + SymbolKind::ToString { .. } => quoted("toString"), + SymbolKind::ToPrimitive => "Symbol.toPrimitive".into(), + SymbolKind::ToStringTag { .. } => "Symbol.toStringTag".into(), + SymbolKind::Iterator { .. } => "Symbol.iterator".into(), + SymbolKind::CollectionLength => quoted("length"), + SymbolKind::CollectionAt { .. } => quoted("at"), + SymbolKind::CollectionToArray { .. } => quoted("toArray"), + SymbolKind::IteratorNext { .. } => quoted("next"), + }], + _ => Vec::new(), + } +} + /// Returns a dedup key for a SymbolKind so flatten can detect duplicate symbols. pub fn symbol_dedup_key(kind: &SymbolKind) -> String { match kind { @@ -1502,9 +1672,9 @@ fn collect_known_delegate_names_from_methods( delegate_names: &mut HashSet, ) { for method in methods { - for parameter in &method.params { + for param in &method.params { collect_known_delegate_names_from_type( - ¶meter.typ, + ¶m.typ, known_delegate_names, delegate_names, ); @@ -1543,9 +1713,9 @@ fn collect_known_delegate_names_from_type( collect_known_delegate_names_from_type(progress, known_delegate_names, delegate_names); } TypeMeta::Parameterized { name, args, .. } => { - let concrete = crate::meta::make_parameterized_name(name, args); - if known_delegate_names.contains(&concrete) { - delegate_names.insert(concrete); + let parameterized_name = crate::meta::make_parameterized_name(name, args); + if known_delegate_names.contains(¶meterized_name) { + delegate_names.insert(parameterized_name); } for argument in args { collect_known_delegate_names_from_type( diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs index a7658161..2b47cce8 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs @@ -113,6 +113,21 @@ fn render_esm(file: &ProjectedFile) -> String { } // Classes + if file + .classes + .iter() + .any(|class| !class.shared_interface_members.is_empty()) + { + out.push_str( + "const __copyInterfaceMembers = (target, source, keys) => {\n\ + for (const key of keys) {\n\ + const descriptor = Object.getOwnPropertyDescriptor(source.prototype, key);\n\ + if (descriptor === undefined) throw new Error(`Missing shared interface member ${String(key)}`);\n\ + Object.defineProperty(target.prototype, key, descriptor);\n\ + }\n\ +};\n\n", + ); + } for class in &file.classes { render_class_js(&mut out, class); @@ -251,6 +266,11 @@ fn render_class_js(out: &mut String, class: &ProjectedClass) { // Members — handle same-name overloads (from OverloadAttribute merging) out.push('\n'); + let shared_member_keys: std::collections::HashSet<&str> = class + .shared_interface_members + .iter() + .flat_map(|group| group.member_keys.iter().map(String::as_str)) + .collect(); let mut emitted_names: std::collections::HashSet = std::collections::HashSet::new(); // Group same-name methods within class.members let mut same_class_groups: std::collections::HashMap> = @@ -267,6 +287,12 @@ fn render_class_js(out: &mut String, class: &ProjectedClass) { } for (i, member) in class.members.iter().enumerate() { + if member_render_key(member) + .as_deref() + .is_some_and(|key| shared_member_keys.contains(key)) + { + continue; + } if let ProjectedMember::Method(method) = member { // Skip if already emitted as part of a group if !emitted_names.insert(method.name.clone()) { @@ -302,12 +328,27 @@ fn render_class_js(out: &mut String, class: &ProjectedClass) { render_member_js(out, member, &class.name); } out.push_str("}\n"); + for shared in &class.shared_interface_members { + out.push_str(&format!( + "__copyInterfaceMembers({}, {}, [{}]);\n", + class.name, + ref_marker(&shared.interface_name), + shared.descriptor_keys.join(", "), + )); + } } fn render_iface_js(out: &mut String, iface: &ProjectedIface, _file: &ProjectedFile) { if let Some(ref doc) = iface.doc { out.push_str(&render_jsdoc(doc, "")); } + if iface.shared_member_source { + out.push_str("const __interfaceInstances = new WeakSet();\n"); + out.push_str(&format!( + "const __interfaceValue = (value) => __interfaceInstances.has(value) ? value._obj : value._obj.cast(IID_{});\n", + iface.name + )); + } out.push_str(&format!("export class {} {{\n", iface.name)); out.push_str(" _obj;\n\n"); @@ -318,12 +359,14 @@ fn render_iface_js(out: &mut String, iface: &ProjectedIface, _file: &ProjectedFi " this._obj = obj.cast(IID_{});\n", iface.name )); - out.push_str(" }\n"); } else { out.push_str(" constructor(obj) {\n"); out.push_str(" this._obj = obj;\n"); - out.push_str(" }\n"); } + if iface.shared_member_source { + out.push_str(" __interfaceInstances.add(this);\n"); + } + out.push_str(" }\n"); // static from() if iface.has_static_from { @@ -363,6 +406,22 @@ fn render_required_iface_js(out: &mut String, ri: &ProjectedRequiredIface) { out.push_str("}\n"); } +fn member_render_key(member: &ProjectedMember) -> Option { + match member { + ProjectedMember::Method(method) => Some(method.name.clone()), + ProjectedMember::Property(property) => Some(property.name.clone()), + ProjectedMember::Event(event) if !event.subscribe_name.is_empty() => { + Some(event.subscribe_name.clone()) + } + ProjectedMember::Symbol(symbol) => { + Some(crate::codegen::winrt::javascript::project::symbol_dedup_key(&symbol.kind)) + } + ProjectedMember::Close => Some("close".into()), + ProjectedMember::AsCast => Some("as".into()), + _ => None, + } +} + fn render_member_js(out: &mut String, member: &ProjectedMember, _class_name: &str) { match member { ProjectedMember::Constructor(ctor) => { diff --git a/tools/dynwinrt-codegen/src/main.rs b/tools/dynwinrt-codegen/src/main.rs index bfe2b110..d80c51e2 100644 --- a/tools/dynwinrt-codegen/src/main.rs +++ b/tools/dynwinrt-codegen/src/main.rs @@ -13,6 +13,11 @@ use dynwinrt_codegen::codegen::package; use dynwinrt_codegen::codegen::python; use dynwinrt_codegen::codegen::typescript; use dynwinrt_codegen::codegen::winrt::extensions::winui; +use dynwinrt_codegen::codegen::winrt::javascript::bundle::{ + BindingBundleSpec, GeneratedBindingBundle, binding_bundle_redirect, binding_output_file_path, + collect_binding_bundle_modules, generate_binding_bundle_with_modules, + parse_binding_bundle_spec, validate_binding_file_stem, +}; use dynwinrt_codegen::codegen::{project, render_dts, render_js}; use dynwinrt_codegen::com_metadata; use dynwinrt_codegen::meta; @@ -125,6 +130,11 @@ enum Commands { #[arg(long, default_value = "@microsoft/dynwinrt", value_name = "NAME")] import_name: String, + /// Reuse standalone interface prototype implementations for inherited + /// concrete-class members. Opt-in while compatibility data is gathered. + #[arg(long)] + shared_interface_members: bool, + /// Validate metadata and resolve dependencies without writing files #[arg(long)] dry_run: bool, @@ -137,6 +147,16 @@ enum Commands { #[arg(long, conflicts_with = "pyi")] no_pyi: bool, }, + /// Bundle an already-generated JavaScript binding directory. + Bundle { + /// Generated binding directory to bundle in place. + #[arg(long, value_name = "DIR")] + output: String, + + /// Repeatable bundle definition: NAME=MODULE[,MODULE...]. + #[arg(long = "bundle", value_name = "NAME=MODULE[,MODULE...]")] + bundles: Vec, + }, } const COM_MANIFEST_FILE: &str = ".dynwinrt-com-manifest.json"; @@ -236,6 +256,20 @@ fn parse_class_requests( .collect() } +fn parse_binding_bundle_specs(values: &[String]) -> Result, String> { + let mut names = HashSet::new(); + values + .iter() + .map(|value| { + let spec = parse_binding_bundle_spec(value)?; + if !names.insert(spec.name.clone()) { + return Err(format!("Duplicate --bundle name `{}`", spec.name)); + } + Ok(spec) + }) + .collect() +} + fn run() -> Result<(), String> { let cli = Cli::parse(); @@ -246,6 +280,22 @@ fn run() -> Result<(), String> { Commands::ComCensus { winmd, json } => { run_com_census(&winmd, json)?; } + Commands::Bundle { output, bundles } => { + let bundles = parse_binding_bundle_specs(&bundles)?; + if bundles.is_empty() { + return Err("bundle requires at least one --bundle specification".into()); + } + let output = PathBuf::from(output); + if !output.is_dir() { + return Err(format!( + "Generated binding directory does not exist: {}", + output.display(), + )); + } + let transaction = OutputTransaction::begin(&output)?; + write_js_barrel_and_manifest(transaction.stage_dir(), "", &bundles)?; + transaction.commit()?; + } Commands::Generate { winmd, winmd_list, @@ -257,6 +307,7 @@ fn run() -> Result<(), String> { lang, output, import_name, + shared_interface_members, dry_run, pyi, no_pyi, @@ -264,6 +315,10 @@ fn run() -> Result<(), String> { if lang != "py" && (pyi || no_pyi) { return Err("--pyi and --no-pyi require --lang py".into()); } + if lang != "js" && shared_interface_members { + return Err("--shared-interface-members requires --lang js".into()); + } + let binding_bundles: Vec = Vec::new(); let pyi = lang == "py" && !no_pyi; // Collect winmd paths from --folder and/or --winmd let mut winmd_parts: Vec = Vec::new(); @@ -359,7 +414,7 @@ fn run() -> Result<(), String> { let final_output_dir = Path::new(&output); let mut python_output = if lang == "py" && !dry_run { - Some(PythonOutputTransaction::begin(final_output_dir)?) + Some(OutputTransaction::begin(final_output_dir)?) } else { None }; @@ -370,12 +425,14 @@ fn run() -> Result<(), String> { let output_dir = effective_output_dir.as_path(); if lang == "js" { project::set_import_name(&import_name); + project::set_shared_interface_members(shared_interface_members); } if !dry_run { fs::create_dir_all(output_dir).map_err(|e| { format!("Failed to create output directory '{}': {}", output, e) })?; if lang == "js" { + ensure_unbundled_js_generation_output(output_dir)?; migrate_legacy_com_only_package(output_dir)?; } } @@ -675,7 +732,7 @@ fn run() -> Result<(), String> { } else { generate_fn(&all_classes, &all_interfaces, &all_enums) }; - write_js_barrel_and_manifest(output_dir, &index_content)?; + write_js_barrel_and_manifest(output_dir, &index_content, &binding_bundles)?; } } } else { @@ -821,7 +878,7 @@ fn run() -> Result<(), String> { } else { let index_code = typescript::generate_index(&all_classes, &all_interfaces, &all_enums); - write_js_barrel_and_manifest(output_dir, &index_code)?; + write_js_barrel_and_manifest(output_dir, &index_code, &binding_bundles)?; } } @@ -1266,12 +1323,17 @@ fn generate_js_files( /// are removed cleanly. Then we scan the directory for real `.js` files (each /// one corresponds to a subpath consumer can deep-import) and emit a /// `package.json` with the conditional-exports map. -fn write_js_barrel_and_manifest(output_dir: &Path, index_content: &str) -> Result<(), String> { +fn write_js_barrel_and_manifest( + output_dir: &Path, + index_content: &str, + bundles: &[BindingBundleSpec], +) -> Result<(), String> { let js_path = output_dir.join("index.js"); let mjs_path = output_dir.join("index.mjs"); let proxy_path = output_dir.join("index.proxy.js"); let dts_path = output_dir.join("index.d.ts"); let _ = index_content; + preflight_binding_bundles(output_dir, bundles)?; write_lifetime_module(output_dir)?; // Clean up any stale `.index.ts` cache from older codegen versions. @@ -1291,14 +1353,16 @@ fn write_js_barrel_and_manifest(output_dir: &Path, index_content: &str) -> Resul // Sweep index.js and any other files that still reference sibling modules // that were skipped by class/interface filters during emission. strip_broken_imports(output_dir)?; - // Build the barrel from what actually landed on disk rather than from raw // metadata. This avoids root ESM/CJS barrels referencing files or helper // exports that were filtered out (for example ref-only WinUI controls such // as CompositionTarget). let index_content = render_index_from_existing_js_files(output_dir)?; + let (bundle_overrides, prepared_bundles) = prepare_binding_bundles(output_dir, bundles)?; + write_binding_bundles(output_dir, &prepared_bundles)?; - let js_content = typescript::esm_index_to_cjs_getter(&index_content); + let js_content = + typescript::esm_index_to_cjs_getter_with_overrides(&index_content, &bundle_overrides); fs::write(&js_path, &js_content) .map_err(|e| format!("Failed to write {}: {}", js_path.display(), e))?; @@ -1401,6 +1465,259 @@ fn apply_com_generation_manifest( .map_err(|error| format!("Failed to write {}: {error}", path.display())) } +fn preflight_binding_bundles( + output_dir: &Path, + bundles: &[BindingBundleSpec], +) -> Result<(), String> { + if bundles.is_empty() { + return Ok(()); + } + + let artifacts = collect_existing_binding_bundle_artifacts(output_dir)?; + if !artifacts.is_empty() { + return Err(format!( + "Binding output {} already contains bundle artifacts or redirect shims: {}. \ + Refusing to rebundle potentially stale embedded sources. Regenerate into a new or \ + cleaned unbundled output directory, then run `bundle` once.", + output_dir.display(), + artifacts.into_iter().collect::>().join(", "), + )); + } + for bundle in bundles { + let js_path = + binding_output_file_path(output_dir, &bundle.name, "js", "bundle output name")?; + let dts_path = + binding_output_file_path(output_dir, &bundle.name, "d.ts", "bundle output name")?; + if js_path.exists() || dts_path.exists() { + return Err(format!( + "Bundle name `{}` collides with an existing generated module", + bundle.name + )); + } + let missing_roots = bundle + .modules + .iter() + .map(|module| { + binding_output_file_path(output_dir, module, "js", "configured bundle root") + .map(|path| (module, path)) + }) + .collect::, _>>()? + .into_iter() + .filter_map(|(module, path)| (!path.is_file()).then_some(module.as_str())) + .collect::>(); + if !missing_roots.is_empty() { + return Err(format!( + "Bundle `{}` cannot be created because configured root module(s) are missing: {}. \ + Regenerate or copy a complete unbundled binding output and retry.", + bundle.name, + missing_roots.join(", "), + )); + } + } + Ok(()) +} + +fn ensure_unbundled_js_generation_output(output_dir: &Path) -> Result<(), String> { + let artifacts = collect_existing_binding_bundle_artifacts(output_dir)?; + if !artifacts.is_empty() { + return Err(format!( + "Binding output {} already contains bundle artifacts or redirect shims: {}. \ + Refusing in-place generation because stale bundle files would remain publishable. \ + Generate into a new or cleaned unbundled output directory instead.", + output_dir.display(), + artifacts.into_iter().collect::>().join(", "), + )); + } + Ok(()) +} + +fn collect_existing_binding_bundle_artifacts( + output_dir: &Path, +) -> Result, String> { + let mut artifacts = BTreeSet::new(); + let global_inventory = output_dir.join(".dynwinrt-binding-bundles"); + if global_inventory.exists() { + artifacts.insert(".dynwinrt-binding-bundles".to_string()); + if global_inventory.is_file() { + for name in fs::read_to_string(&global_inventory) + .map_err(|error| format!("Failed to read {}: {error}", global_inventory.display()))? + .lines() + .filter(|line| !line.is_empty()) + { + validate_binding_file_stem(name, "bundle inventory name")?; + } + } + } + + let entries = fs::read_dir(output_dir).map_err(|error| { + format!( + "Failed to inspect binding output directory {}: {error}", + output_dir.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to inspect an entry in binding output directory {}: {error}", + output_dir.display() + ) + })?; + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if let Some(bundle_name) = file_name.strip_prefix(".dynwinrt-binding-bundle-") { + validate_binding_file_stem(bundle_name, "bundle inventory name")?; + artifacts.insert(file_name.to_string()); + if path.is_file() { + for module in fs::read_to_string(&path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))? + .lines() + .filter(|line| !line.is_empty()) + { + binding_output_file_path(output_dir, module, "js", "bundle inventory module")?; + } + } + continue; + } + if !file_name.ends_with(".js") || !path.is_file() { + continue; + } + let source = fs::read_to_string(&path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + if source.contains("Object.defineProperty(exports, '__dynwinrtLoadBundledModule'") + || source.contains(".__dynwinrtLoadBundledModule(") + { + artifacts.insert(file_name.to_string()); + } + } + + Ok(artifacts) +} + +fn prepare_binding_bundles( + output_dir: &Path, + bundles: &[BindingBundleSpec], +) -> Result< + ( + BTreeMap, + Vec<(String, GeneratedBindingBundle)>, + ), + String, +> { + let mut overrides = BTreeMap::new(); + let mut generated_bundles = Vec::new(); + let closures = bundles + .iter() + .map(|bundle| { + collect_binding_bundle_modules(output_dir, bundle).map(|modules| (bundle, modules)) + }) + .collect::, _>>()?; + + let mut explicit_owners = BTreeMap::::new(); + for bundle in bundles { + for module in &bundle.modules { + if let Some(existing) = explicit_owners.insert(module.clone(), bundle.name.clone()) { + return Err(format!( + "Configured root module `{module}` belongs to both bundle `{existing}` and `{}`", + bundle.name, + )); + } + } + } + + let mut closure_memberships = BTreeMap::>::new(); + for (bundle, modules) in &closures { + for module in modules { + closure_memberships + .entry(module.clone()) + .or_default() + .push(bundle.name.clone()); + } + } + + let module_owners = closure_memberships + .into_iter() + .filter_map(|(module, owners)| { + if let Some(explicit_owner) = explicit_owners.get(&module) { + Some((module, explicit_owner.clone())) + } else if owners.len() == 1 { + Some((module, owners[0].clone())) + } else { + None + } + }) + .collect::>(); + + for (bundle, closure) in closures { + let included_modules = closure + .into_iter() + .filter(|module| module_owners.get(module) == Some(&bundle.name)) + .collect::>(); + let generated = + generate_binding_bundle_with_modules(output_dir, bundle, &included_modules)?; + for export in &generated.exports { + if let Some(existing) = overrides.insert(export.clone(), bundle.name.clone()) { + return Err(format!( + "Binding export `{export}` is configured in both bundle `{existing}` and `{}`", + bundle.name + )); + } + } + generated_bundles.push((bundle.name.clone(), generated)); + } + Ok((overrides, generated_bundles)) +} + +fn write_binding_bundles( + output_dir: &Path, + generated_bundles: &[(String, GeneratedBindingBundle)], +) -> Result<(), String> { + for (bundle_name, generated) in generated_bundles { + let js_path = + binding_output_file_path(output_dir, bundle_name, "js", "bundle output name")?; + let dts_path = + binding_output_file_path(output_dir, bundle_name, "d.ts", "bundle output name")?; + fs::write(&js_path, &generated.js) + .map_err(|error| format!("Failed to write {}: {error}", js_path.display()))?; + fs::write(&dts_path, &generated.dts) + .map_err(|error| format!("Failed to write {}: {error}", dts_path.display()))?; + println!( + "Generated {} ({} modules, {} source bytes)", + js_path.display(), + generated.module_count, + generated.bundled_source_bytes, + ); + for module in &generated.modules { + let module_path = binding_output_file_path(output_dir, module, "js", "bundled module")?; + fs::write(&module_path, binding_bundle_redirect(bundle_name, module)) + .map_err(|error| format!("Failed to write {}: {error}", module_path.display(),))?; + } + fs::write( + output_dir.join(format!(".dynwinrt-binding-bundle-{bundle_name}")), + generated + .modules + .iter() + .map(String::as_str) + .collect::>() + .join("\n"), + ) + .map_err(|error| format!("Failed to write bundle module inventory: {error}",))?; + } + if !generated_bundles.is_empty() { + fs::write( + output_dir.join(".dynwinrt-binding-bundles"), + generated_bundles + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join("\n"), + ) + .map_err(|error| format!("Failed to write binding bundle inventory: {error}",))?; + } + Ok(()) +} + fn write_com_js_barrel(com_output_dir: &Path) -> Result<(), String> { let mut modules: BTreeMap> = BTreeMap::new(); let mut contents: BTreeMap = BTreeMap::new(); @@ -1828,6 +2145,9 @@ fn render_index_from_existing_js_files(output_dir: &Path) -> Result c, Err(_) => continue, }; + if content.contains("Object.defineProperty(exports, '__dynwinrtLoadBundledModule'") { + continue; + } let mut names = collect_public_exports_from_js(&content); names.sort(); names.dedup(); @@ -2804,20 +3124,20 @@ fn validate_python_public_identities( Ok(()) } -struct PythonOutputTransaction { +struct OutputTransaction { final_dir: PathBuf, stage_dir: PathBuf, backup_dir: PathBuf, committed: bool, } -impl PythonOutputTransaction { +impl OutputTransaction { fn begin(final_dir: &Path) -> Result { let parent = final_dir.parent().unwrap_or_else(|| Path::new(".")); let leaf = final_dir .file_name() .and_then(|name| name.to_str()) - .ok_or_else(|| format!("Invalid Python output directory '{}'", final_dir.display()))?; + .ok_or_else(|| format!("Invalid output directory '{}'", final_dir.display()))?; fs::create_dir_all(parent) .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?; @@ -2830,7 +3150,7 @@ impl PythonOutputTransaction { if final_dir.exists() { if !final_dir.is_dir() { return Err(format!( - "Python output path '{}' is not a directory", + "Output path '{}' is not a directory", final_dir.display() )); } @@ -2868,7 +3188,7 @@ impl PythonOutputTransaction { if had_existing_output { if let Err(rollback_error) = fs::rename(&self.backup_dir, &self.final_dir) { return Err(format!( - "Failed to replace Python output directory '{}': {}. Rollback also failed: \ + "Failed to replace output directory '{}': {}. Rollback also failed: \ {}. The original output remains at '{}'", self.final_dir.display(), error, @@ -2878,7 +3198,7 @@ impl PythonOutputTransaction { } } return Err(format!( - "Failed to replace Python output directory '{}': {}", + "Failed to replace output directory '{}': {}", self.final_dir.display(), error )); @@ -2888,7 +3208,7 @@ impl PythonOutputTransaction { if had_existing_output { fs::remove_dir_all(&self.backup_dir).map_err(|e| { format!( - "Replaced Python output but failed to remove backup '{}': {}", + "Replaced output but failed to remove backup '{}': {}", self.backup_dir.display(), e ) @@ -2898,7 +3218,7 @@ impl PythonOutputTransaction { } } -impl Drop for PythonOutputTransaction { +impl Drop for OutputTransaction { fn drop(&mut self) { if !self.committed && self.stage_dir.exists() { let _ = fs::remove_dir_all(&self.stage_dir); @@ -3269,6 +3589,8 @@ fn print_capabilities() { "input.winmd-list", "input.ref-list", "selector.namespace-class", + "layout.shared-interface-members", + "layout.bundle", ] { println!("{}", capability); } @@ -3350,6 +3672,47 @@ mod tests { )) } + #[test] + fn js_barrel_uses_current_module_exports_instead_of_stale_index_exports() { + let output = test_directory("stale-js-index"); + let _ = fs::remove_dir_all(&output); + fs::create_dir_all(&output).unwrap(); + fs::write( + output.join("Widget.js"), + "exports.CurrentWidget = class CurrentWidget {};\n", + ) + .unwrap(); + fs::write( + output.join("index.d.ts"), + "export { StaleWidget } from './Widget.js';\n", + ) + .unwrap(); + + let index = render_index_from_existing_js_files(&output).unwrap(); + + assert!(index.contains("CurrentWidget")); + assert!(!index.contains("StaleWidget")); + fs::remove_dir_all(output).unwrap(); + } + + #[test] + fn js_generation_rejects_in_place_output_with_bundle_shims() { + let output = test_directory("bundled-js-generation"); + let _ = fs::remove_dir_all(&output); + fs::create_dir_all(&output).unwrap(); + fs::write( + output.join("Widget.js"), + binding_bundle_redirect("first-screen", "Widget"), + ) + .unwrap(); + + let error = ensure_unbundled_js_generation_output(&output).unwrap_err(); + + assert!(error.contains("Refusing in-place generation")); + assert!(error.contains("new or cleaned unbundled output directory")); + fs::remove_dir_all(output).unwrap(); + } + #[test] fn com_barrel_deduplicates_only_identical_pod_factories() { let descriptor = @@ -3596,7 +3959,7 @@ mod tests { fs::create_dir_all(&output).unwrap(); fs::write(output.join("existing.py"), "old").unwrap(); - let transaction = PythonOutputTransaction::begin(&output).unwrap(); + let transaction = OutputTransaction::begin(&output).unwrap(); fs::write(transaction.stage_dir().join("existing.py"), "new").unwrap(); fs::write(transaction.stage_dir().join("added.py"), "added").unwrap(); transaction.commit().unwrap(); @@ -3619,7 +3982,7 @@ mod tests { fs::write(output.join("existing.py"), "old").unwrap(); { - let transaction = PythonOutputTransaction::begin(&output).unwrap(); + let transaction = OutputTransaction::begin(&output).unwrap(); fs::write(transaction.stage_dir().join("existing.py"), "new").unwrap(); } diff --git a/tools/dynwinrt-codegen/tests/binding_bundle_test.rs b/tools/dynwinrt-codegen/tests/binding_bundle_test.rs new file mode 100644 index 00000000..01723e85 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/binding_bundle_test.rs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use dynwinrt_codegen::codegen::winrt::javascript::bundle::{ + BindingBundleSpec, binding_bundle_redirect, generate_binding_bundle, +}; + +fn test_directory(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("{name}-{}", std::process::id())) +} + +fn run_bundle(directory: &Path, spec: &str) -> Output { + run_bundles(directory, &[spec]) +} + +fn run_bundles(directory: &Path, specs: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")); + command.arg("bundle").arg("--output").arg(directory); + for spec in specs { + command.arg("--bundle").arg(spec); + } + command.output().expect("run dynwinrt-codegen bundle") +} + +#[test] +fn bundle_preserves_cycles_externals_named_exports_and_dts_paths() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping binding bundle runtime test: node is unavailable"); + return; + } + + let directory = test_directory("binding-bundle-runtime"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("A.js"), + "\ +exports.AName = 'A';\n\ +exports.A = class A {};\n\ +exports.B = class WrongB {};\n\ +const b = require('./B.js');\n\ +const lifetime = require('./lifetime.js');\n\ +const path = require('node:path');\n\ +exports.APeer = () => b.BName;\n\ +exports.Track = lifetime.track;\n\ +exports.Separator = path.sep;\n", + ) + .unwrap(); + fs::write( + directory.join("lifetime.js"), + "\ +const tracked = new Set();\n\ +exports.track = (value) => { tracked.add(value); return value; };\n\ +exports.count = () => tracked.size;\n", + ) + .unwrap(); + fs::write( + directory.join("A.d.ts"), + "\ +export declare const AName: string;\n\ +export declare class A {}\n\ +export declare const APeer: () => string;\n\ +export declare const Track: (value: object) => object;\n\ +export declare const Separator: string;\n", + ) + .unwrap(); + fs::write( + directory.join("B.js"), + "\ +exports.BName = 'B';\n\ +exports.B = class B {};\n\ +const a = require('./A.js');\n\ +exports.BPeer = () => a.AName;\n", + ) + .unwrap(); + fs::write( + directory.join("B.d.ts"), + "\ +export declare const BName: string;\n\ +export declare const BPeer: () => string;\n", + ) + .unwrap(); + + let generated = generate_binding_bundle( + &directory, + &BindingBundleSpec { + name: "first-screen".into(), + modules: vec!["A".into(), "B".into()], + }, + ) + .unwrap(); + assert_eq!(generated.module_count, 3); + assert!(generated.js.contains("require('node:path')")); + assert!(generated.js.contains("get: () => __load('./A.js').AName",)); + assert!(generated.js.contains("get: () => __load('./B.js').BName",)); + assert!(generated.exports.contains("track")); + assert!(generated.dts.contains("from './A.js';")); + assert!(generated.dts.contains("from './B.js';")); + + fs::write(directory.join("first-screen.js"), generated.js).unwrap(); + fs::write(directory.join("first-screen.d.ts"), generated.dts).unwrap(); + for module in &generated.modules { + fs::write( + directory.join(format!("{module}.js")), + binding_bundle_redirect("first-screen", module), + ) + .unwrap(); + } + fs::write( + directory.join("test.js"), + "\ +const assert = require('node:assert/strict');\n\ +const bundle = require('./first-screen.js');\n\ +const deepA = require('./A.js');\n\ +const deepB = require('./B.js');\n\ +const deepLifetime = require('./lifetime.js');\n\ +assert.equal(bundle.AName, 'A');\n\ +assert.equal(bundle.BName, 'B');\n\ +assert.equal(bundle.APeer(), 'B');\n\ +assert.equal(bundle.BPeer(), 'A');\n\ +assert.equal(typeof bundle.Separator, 'string');\n\ +assert.strictEqual(bundle.A, deepA.A);\n\ +assert.strictEqual(bundle.B, deepB.B);\n\ +assert.strictEqual(bundle.Track, deepLifetime.track);\n\ +bundle.Track({});\n\ +assert.equal(deepLifetime.count(), 1);\n", + ) + .unwrap(); + + let output = Command::new("node") + .arg("test.js") + .current_dir(&directory) + .output() + .unwrap(); + assert!( + output.status.success(), + "node failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + fs::write( + directory.join("test.mjs"), + "\ +import assert from 'node:assert/strict';\n\ +import { A, AName } from './first-screen.js';\n\ +import { A as DeepA, AName as DeepAName } from './A.js';\n\ +assert.strictEqual(A, DeepA);\n\ +assert.equal(AName, DeepAName);\n", + ) + .unwrap(); + let output = Command::new("node") + .arg("test.mjs") + .current_dir(&directory) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "node ESM failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn bundle_rerun_rejects_stale_existing_artifacts() { + let directory = test_directory("binding-bundle-stale-rerun"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("A.js"), "exports.A = 'old';\n").unwrap(); + fs::write( + directory.join("A.d.ts"), + "export declare const A: string;\n", + ) + .unwrap(); + + let first = run_bundle(&directory, "first-screen=A"); + assert!( + first.status.success(), + "initial bundle failed:\n{}", + String::from_utf8_lossy(&first.stderr) + ); + let original_bundle = fs::read_to_string(directory.join("first-screen.js")).unwrap(); + fs::write(directory.join("A.js"), "exports.A = 'new';\n").unwrap(); + + let rerun = run_bundle(&directory, "first-screen=A"); + let stderr = String::from_utf8_lossy(&rerun.stderr); + assert!(!rerun.status.success(), "rebundling unexpectedly succeeded"); + assert!(stderr.contains("already contains bundle artifacts or redirect shims")); + assert!(stderr.contains("new or cleaned unbundled output directory")); + assert_eq!( + fs::read_to_string(directory.join("A.js")).unwrap(), + "exports.A = 'new';\n" + ); + assert_eq!( + fs::read_to_string(directory.join("first-screen.js")).unwrap(), + original_bundle + ); + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn bundle_rejects_malicious_inventory_paths_without_writing_outside_output() { + let directory = test_directory("binding-bundle-malicious-inventory"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("A.js"), "exports.A = 'A';\n").unwrap(); + let escaped_name = format!("binding-bundle-escaped-{}", std::process::id()); + let escaped = directory + .parent() + .unwrap() + .join(format!("{escaped_name}.js")); + let _ = fs::remove_file(&escaped); + fs::write(directory.join(".dynwinrt-binding-bundles"), "first-screen").unwrap(); + fs::write( + directory.join(".dynwinrt-binding-bundle-first-screen"), + format!("../{escaped_name}"), + ) + .unwrap(); + + let output = run_bundle(&directory, "first-screen=A"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "malicious inventory was accepted"); + assert!(stderr.contains("Invalid bundle inventory module")); + assert!(!escaped.exists()); + assert!(!directory.join("lifetime.js").exists()); + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn bundle_fails_atomically_when_a_configured_root_is_missing() { + let directory = test_directory("binding-bundle-missing-root"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("A.js"), "exports.A = 'A';\n").unwrap(); + + let output = run_bundle(&directory, "first-screen=A,Missing"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "bundle with a missing root succeeded" + ); + assert!(stderr.contains("configured root module(s) are missing: Missing")); + assert!(!directory.join("first-screen.js").exists()); + assert!(!directory.join("lifetime.js").exists()); + assert_eq!( + fs::read_to_string(directory.join("A.js")).unwrap(), + "exports.A = 'A';\n" + ); + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn bundle_fails_atomically_when_a_relative_dependency_is_missing() { + let directory = test_directory("binding-bundle-missing-dependency"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("A.js"), + "const b = require('./B.js');\nexports.A = () => b.B;\n", + ) + .unwrap(); + + let output = run_bundle(&directory, "first-screen=A"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "bundle with an incomplete closure succeeded" + ); + assert!(stderr.contains("dependency closure is incomplete")); + assert!(stderr.contains("requires missing generated sibling `B`")); + assert!(!directory.join("first-screen.js").exists()); + assert!(!directory.join("lifetime.js").exists()); + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn multiple_bundles_share_common_dependencies_without_duplicate_identity() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping multi-bundle identity test: node is unavailable"); + return; + } + + let directory = test_directory("binding-bundle-shared-dependency"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("A.js"), + "const lifetime = require('./lifetime.js');\n\ + exports.A = class A {};\n\ + exports.TrackA = lifetime.projectAs;\n", + ) + .unwrap(); + fs::write( + directory.join("B.js"), + "const lifetime = require('./lifetime.js');\n\ + exports.B = class B {};\n\ + exports.TrackB = lifetime.projectAs;\n", + ) + .unwrap(); + + let output = run_bundles(&directory, &["first=A", "second=B"]); + assert!( + output.status.success(), + "multi-bundle generation failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + fs::read_to_string(directory.join("lifetime.js")) + .unwrap() + .contains("exports.projectAs = projectAs;") + ); + assert!( + !fs::read_to_string(directory.join(".dynwinrt-binding-bundle-first")) + .unwrap() + .contains("lifetime") + ); + assert!( + !fs::read_to_string(directory.join(".dynwinrt-binding-bundle-second")) + .unwrap() + .contains("lifetime") + ); + fs::write( + directory.join("multi-test.js"), + "\ +const assert = require('node:assert/strict');\n\ +const first = require('./first.js');\n\ +const second = require('./second.js');\n\ +const lifetime = require('./lifetime.js');\n\ +assert.strictEqual(first.TrackA, lifetime.projectAs);\n\ +assert.strictEqual(second.TrackB, lifetime.projectAs);\n\ +assert.strictEqual(require('./A.js').A, first.A);\n\ +assert.strictEqual(require('./B.js').B, second.B);\n", + ) + .unwrap(); + let node = Command::new("node") + .arg("multi-test.js") + .current_dir(&directory) + .output() + .unwrap(); + assert!( + node.status.success(), + "multi-bundle runtime failed:\n{}", + String::from_utf8_lossy(&node.stderr) + ); + fs::remove_dir_all(directory).unwrap(); +} diff --git a/tools/dynwinrt-codegen/tests/shared_interface_members_test.rs b/tools/dynwinrt-codegen/tests/shared_interface_members_test.rs new file mode 100644 index 00000000..32f81900 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/shared_interface_members_test.rs @@ -0,0 +1,366 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::process::Command; + +use dynwinrt_codegen::codegen::{project, render_dts, render_js}; +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::types::TypeMeta; + +fn value_interface() -> InterfaceMeta { + InterfaceMeta { + name: "IValue".into(), + namespace: "Contoso".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + methods: vec![ + MethodMeta { + name: "get_Value".into(), + raw_name: "get_Value".into(), + vtable_index: 6, + return_type: Some(TypeMeta::I32), + is_property_getter: true, + ..Default::default() + }, + MethodMeta { + name: "put_Value".into(), + raw_name: "put_Value".into(), + vtable_index: 7, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + is_property_setter: true, + ..Default::default() + }, + ], + ..Default::default() + } +} + +fn widget_class(interface: &InterfaceMeta) -> ClassMeta { + ClassMeta { + name: "Widget".into(), + namespace: "Contoso".into(), + full_name: "Contoso.Widget".into(), + required_interfaces: vec![interface.clone()], + ..Default::default() + } +} + +#[test] +fn opt_in_reuses_shared_interface_descriptors_without_changing_dts() { + let interface = value_interface(); + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IValue".into()]); + let shared_iids = HashSet::from([interface.iid.clone()]); + + project::set_shared_interface_members(true); + let interface_file = project::project_interface( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + let class_file = project::project_class( + &class, + &known_types, + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + + let interface_js = render_js::render(&interface_file); + let class_js = render_js::render(&class_file); + let class_dts = render_dts::render(&class_file); + + assert!(interface_js.contains("const __interfaceInstances = new WeakSet();")); + assert!(interface_js.contains("__interfaceValue(this)")); + assert!(class_js.contains("__copyInterfaceMembers(Widget, (__get_IValue()), ['value']);")); + assert!(!class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); + assert!(class_dts.contains("get value(): number;")); + assert!(class_dts.contains("set value(value: number);")); + + let default_class_js = render_js::render(&project::project_class( + &class, + &known_types, + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + assert!(!default_class_js.contains("__copyInterfaceMembers")); + assert!(default_class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); +} + +#[test] +fn shared_interface_members_preserve_overload_dispatch_and_declarations() { + let interface = InterfaceMeta { + name: "IOverloaded".into(), + namespace: "Contoso".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + methods: vec![ + MethodMeta { + name: "DoThing".into(), + raw_name: "DoThing".into(), + vtable_index: 6, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + ..Default::default() + }, + MethodMeta { + name: "DoThing2".into(), + raw_name: "DoThing".into(), + vtable_index: 7, + params: vec![ + ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ParamMeta { + name: "other".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ], + ..Default::default() + }, + ], + ..Default::default() + }; + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IOverloaded".into()]); + let shared_iids = HashSet::from([interface.iid.clone()]); + + project::set_shared_interface_members(true); + let interface_js = render_js::render(&project::project_interface( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + let class_file = project::project_class( + &class, + &known_types, + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + let class_js = render_js::render(&class_file); + let class_dts = render_dts::render(&class_file); + + assert!(interface_js.contains("doThing(value)"), "{interface_js}"); + assert!( + interface_js.contains("doThing2(value, other)"), + "{interface_js}" + ); + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains("_doThing_1(value)"), "{class_js}"); + assert!(class_js.contains("_doThing_2(value, other)"), "{class_js}"); + assert!(class_js.contains("doThing(...args)"), "{class_js}"); + assert_eq!(class_dts.matches("doThing(").count(), 2); +} + +#[test] +fn shared_interface_members_preserve_cross_interface_overload_dispatch() { + let default_interface = InterfaceMeta { + name: "IWidget".into(), + namespace: "Contoso".into(), + iid: "33333333-3333-3333-3333-333333333333".into(), + methods: vec![MethodMeta { + name: "DoThing2".into(), + raw_name: "DoThing2".into(), + vtable_index: 6, + params: vec![ + ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ParamMeta { + name: "other".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ], + ..Default::default() + }], + ..Default::default() + }; + let required_interface = InterfaceMeta { + name: "IRequired".into(), + namespace: "Contoso".into(), + iid: "44444444-4444-4444-4444-444444444444".into(), + methods: vec![MethodMeta { + name: "DoThing".into(), + raw_name: "DoThing".into(), + vtable_index: 6, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + ..Default::default() + }], + ..Default::default() + }; + let class = ClassMeta { + default_interface: Some(default_interface), + required_interfaces: vec![required_interface.clone()], + ..widget_class(&required_interface) + }; + let known_types = HashSet::from(["Widget".into(), "IWidget".into(), "IRequired".into()]); + let shared_iids = HashSet::from([required_interface.iid.clone()]); + + project::set_shared_interface_members(true); + let class_file = project::project_class( + &class, + &known_types, + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + let class_js = render_js::render(&class_file); + let class_dts = render_dts::render(&class_file); + + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains("_doThing_1(value)"), "{class_js}"); + assert!(class_js.contains("_doThing_2(value, other)"), "{class_js}"); + assert!(class_js.contains("doThing(...args)"), "{class_js}"); + assert_eq!(class_dts.matches("doThing(").count(), 2); +} + +#[test] +fn shared_interface_descriptor_executes_for_raw_and_concrete_views() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping shared-interface runtime test: node is unavailable"); + return; + } + + let interface = value_interface(); + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IValue".into()]); + let shared_iids = HashSet::from([interface.iid.clone()]); + + project::set_import_name("./runtime.js"); + project::set_shared_interface_members(true); + let interface_js = render_js::render(&project::project_interface( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + let class_js = render_js::render(&project::project_class( + &class, + &known_types, + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + project::set_shared_interface_members(false); + project::set_import_name("@microsoft/dynwinrt"); + + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("shared-interface-runtime-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("IValue.js"), interface_js).unwrap(); + fs::write(directory.join("Widget.js"), class_js).unwrap(); + fs::write( + directory.join("lifetime.js"), + "\ +exports.castProjectedValueBorrowed = (value) => value;\n\ +exports.castProjectedValueOwned = (value) => value;\n\ +exports.trackProjectedValue = (value) => value;\n", + ) + .unwrap(); + fs::write( + directory.join("runtime.js"), + "\ +class DynWinRtMethodSig {\n\ + addIn() { return this; }\n\ + addOut() { return this; }\n\ +}\n\ +const DynWinRtType = {\n\ + registerInterface() {\n\ + return {\n\ + addMethod() { return this; },\n\ + method(index) { return { invoke: (obj, args) => obj.invoke(index, args) }; },\n\ + };\n\ + },\n\ + i32() { return {}; },\n\ +};\n\ +const DynWinRtValue = { i32: (value) => value };\n\ +const DynWinRtArray = {};\n\ +const DynWinRtDelegate = {};\n\ +const WinGuid = { parse: (value) => value };\n\ +module.exports = { DynWinRtType, DynWinRtMethodSig, DynWinRtValue, DynWinRtArray, DynWinRtDelegate, WinGuid };\n", + ) + .unwrap(); + fs::write( + directory.join("test.js"), + "\ +const assert = require('node:assert/strict');\n\ +const { IValue } = require('./IValue.js');\n\ +const { Widget } = require('./Widget.js');\n\ +let current = 41;\n\ +let casts = 0;\n\ +const interfaceValue = {\n\ + invoke(index, args) {\n\ + if (index === 6) return { toNumber: () => current };\n\ + if (index === 7) { current = args[0]; return undefined; }\n\ + throw new Error(`unexpected slot ${index}`);\n\ + },\n\ +};\n\ +const raw = { cast() { casts++; return interfaceValue; } };\n\ +const widget = Object.assign(Object.create(Widget.prototype), { _obj: raw });\n\ +const concreteDescriptor = Object.getOwnPropertyDescriptor(Widget.prototype, 'value');\n\ +const interfaceDescriptor = Object.getOwnPropertyDescriptor(IValue.prototype, 'value');\n\ +assert.equal(concreteDescriptor.get, interfaceDescriptor.get);\n\ +assert.equal(widget.value, 41);\n\ +widget.value = 52;\n\ +assert.equal(widget.value, 52);\n\ +const view = IValue.from(raw);\n\ +assert.equal(view.value, 52);\n\ +assert.equal(casts, 4);\n", + ) + .unwrap(); + + let output = Command::new("node") + .arg("test.js") + .current_dir(&directory) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "node failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} From aa7e3d58d6a8a66099bf65f4a01d65ff10476331 Mon Sep 17 00:00:00 2001 From: "Leilei Zhang (from Dev Box)" Date: Tue, 11 Aug 2026 16:09:03 +0800 Subject: [PATCH 2/3] Harden binding bundle generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 84688dea-d965-4933-a2d9-41f8f6ad2f2e --- .../src/codegen/winrt/javascript/bundle.rs | 507 ++++++++++++++++-- .../src/codegen/winrt/javascript/generator.rs | 32 ++ tools/dynwinrt-codegen/src/main.rs | 114 +++- .../tests/binding_bundle_test.rs | 173 ++++++ 4 files changed, 765 insertions(+), 61 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs index efe6433b..6a4c0f16 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs @@ -8,6 +8,9 @@ use std::fs; use std::io::ErrorKind; use std::path::{Path, PathBuf}; +const RESERVED_BUNDLE_NAMES: &[&str] = &["com", "index", "lifetime", "proxy"]; +const RESERVED_BUNDLE_MODULE_NAMES: &[&str] = &["index", "lifetime"]; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindingBundleSpec { pub name: String, @@ -24,37 +27,56 @@ pub struct GeneratedBindingBundle { } pub fn parse_binding_bundle_spec(value: &str) -> Result { - let (name, modules) = value + let (name, module_list) = value .split_once('=') .ok_or_else(|| format!("Invalid --bundle `{value}`; expected NAME=MODULE[,MODULE...]"))?; let name = name.trim(); validate_binding_file_stem(name, "bundle name")?; - if matches!(name, "index" | "index.proxy" | "index.getter" | "lifetime") { + if RESERVED_BUNDLE_NAMES.contains(&portable_binding_name_key(name).as_str()) { return Err(format!("Reserved bundle name `{name}`")); } - let mut modules = modules + let mut modules = Vec::new(); + let mut module_spellings = BTreeMap::::new(); + for module in module_list .split(',') .map(str::trim) .filter(|module| !module.is_empty()) - .map(|module| module.strip_suffix(".js").unwrap_or(module).to_string()) - .collect::>(); + .map(|module| module.strip_suffix(".js").unwrap_or(module)) + { + validate_binding_file_stem(module, "bundle module")?; + let key = portable_binding_name_key(module); + if RESERVED_BUNDLE_MODULE_NAMES.contains(&key.as_str()) { + return Err(format!("Reserved bundle module `{module}`")); + } + if let Some(existing) = module_spellings.get(&key) { + if existing != module { + return Err(format!( + "Bundle `{name}` configures module names `{existing}` and `{module}` that \ + collide case-insensitively" + )); + } + continue; + } + module_spellings.insert(key, module.to_string()); + modules.push(module.to_string()); + } if modules.is_empty() { return Err(format!( "Bundle `{name}` must configure at least one module" )); } - for module in &modules { - validate_binding_file_stem(module, "bundle module")?; - } modules.sort(); - modules.dedup(); Ok(BindingBundleSpec { name: name.to_string(), modules, }) } +pub fn portable_binding_name_key(value: &str) -> String { + value.to_ascii_lowercase() +} + pub fn generate_binding_bundle( output_dir: &Path, spec: &BindingBundleSpec, @@ -72,9 +94,32 @@ pub fn collect_binding_bundle_modules( validate_binding_file_stem(module, "bundle module")?; } + let available_modules = collect_portable_js_module_stems(output_dir)?; let mut modules = BTreeSet::new(); + let mut module_spellings = BTreeMap::::new(); let mut pending = VecDeque::from(spec.modules.clone()); while let Some(module) = pending.pop_front() { + let key = portable_binding_name_key(&module); + if let Some(existing) = available_modules.get(&key) + && existing != &module + { + return Err(format!( + "Bundle `{}` references module `{module}` with non-portable casing; \ + the generated file is `{existing}.js`", + spec.name + )); + } + if let Some(existing) = module_spellings.get(&key) { + if existing != &module { + return Err(format!( + "Bundle `{}` dependency closure contains module names `{existing}` and \ + `{module}` that collide case-insensitively", + spec.name + )); + } + } else { + module_spellings.insert(key, module.clone()); + } if modules.contains(&module) { continue; } @@ -106,6 +151,157 @@ pub fn collect_binding_bundle_modules( Ok(modules) } +fn collect_portable_js_module_stems(output_dir: &Path) -> Result, String> { + let mut stems = BTreeMap::::new(); + let entries = fs::read_dir(output_dir).map_err(|error| { + format!( + "Failed to inspect binding output directory {}: {error}", + output_dir.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to inspect an entry in binding output directory {}: {error}", + output_dir.display() + ) + })?; + if !entry + .file_type() + .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))? + .is_file() + { + continue; + } + let Some(file_name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let Some(stem) = file_name.strip_suffix(".js") else { + continue; + }; + let key = portable_binding_name_key(stem); + if let Some(existing) = stems.get(&key) { + if existing != stem { + return Err(format!( + "Generated module names `{existing}` and `{stem}` collide \ + case-insensitively in {}", + output_dir.display() + )); + } + } else { + stems.insert(key, stem.to_string()); + } + } + Ok(stems) +} + +fn select_export_owners( + exports_by_module: &BTreeMap>, + configured_roots: &[String], + canonical_owners: &BTreeMap, +) -> BTreeMap { + let mut owners = BTreeMap::::new(); + for (export, module) in canonical_owners { + if exports_by_module + .get(module) + .is_some_and(|exports| exports.contains(export)) + { + owners.insert(export.clone(), module.clone()); + } + } + for (module, exports) in exports_by_module { + if exports.contains(module) { + owners + .entry(module.clone()) + .or_insert_with(|| module.clone()); + } + } + for module in configured_roots { + if let Some(exports) = exports_by_module.get(module) { + for export in exports { + owners + .entry(export.clone()) + .or_insert_with(|| module.clone()); + } + } + } + for (module, exports) in exports_by_module { + for export in exports { + owners + .entry(export.clone()) + .or_insert_with(|| module.clone()); + } + } + owners +} + +fn collect_canonical_export_owners( + exports_by_module: &BTreeMap>, +) -> BTreeMap { + let mut owners = BTreeMap::new(); + for (module, exports) in exports_by_module { + if exports.contains(module) { + owners.insert(module.clone(), module.clone()); + } + } + for (module, exports) in exports_by_module { + for export in exports { + owners + .entry(export.clone()) + .or_insert_with(|| module.clone()); + } + } + owners +} + +fn collect_output_export_owners( + output_dir: &Path, + extension: &str, + collect_exports: fn(&str) -> BTreeSet, +) -> Result, String> { + let mut exports_by_module = BTreeMap::new(); + let suffix = format!(".{extension}"); + let entries = fs::read_dir(output_dir).map_err(|error| { + format!( + "Failed to inspect binding output directory {}: {error}", + output_dir.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to inspect an entry in binding output directory {}: {error}", + output_dir.display() + ) + })?; + if !entry + .file_type() + .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))? + .is_file() + { + continue; + } + let Some(file_name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let Some(module) = file_name.strip_suffix(&suffix) else { + continue; + }; + if matches!(module, "index" | "index.proxy" | "index.getter") { + continue; + } + let source = fs::read_to_string(entry.path()) + .map_err(|error| format!("Failed to read {}: {error}", entry.path().display()))?; + if extension == "js" + && source.contains("Object.defineProperty(exports, '__dynwinrtLoadBundledModule'") + { + continue; + } + exports_by_module.insert(module.to_string(), collect_exports(&source)); + } + Ok(collect_canonical_export_owners(&exports_by_module)) +} + pub fn generate_binding_bundle_with_modules( output_dir: &Path, spec: &BindingBundleSpec, @@ -135,31 +331,39 @@ pub fn generate_binding_bundle_with_modules( modules.insert(module.clone(), source); } - let mut export_owners = BTreeMap::::new(); - for (module, source) in &modules { - for export in collect_cjs_exports(source) { - if export == *module { - export_owners.insert(export, module.clone()); - } - } - } - for module in &spec.modules { - let source = modules - .get(module) - .ok_or_else(|| format!("Bundle `{}` did not load `{module}`", spec.name))?; - for export in collect_cjs_exports(source) { - export_owners - .entry(export) - .or_insert_with(|| module.clone()); - } - } - for (module, source) in &modules { - for export in collect_cjs_exports(source) { - export_owners - .entry(export) - .or_insert_with(|| module.clone()); - } - } + let runtime_exports_by_module = modules + .iter() + .map(|(module, source)| (module.clone(), collect_cjs_exports(source))) + .collect::>(); + let canonical_runtime_owners = + collect_output_export_owners(output_dir, "js", collect_cjs_exports)?; + let export_owners = select_export_owners( + &runtime_exports_by_module, + &spec.modules, + &canonical_runtime_owners, + ); + + let declaration_exports_by_module = modules + .keys() + .map(|module| { + let path = binding_output_file_path(output_dir, module, "d.ts", "bundle declaration")?; + let exports = match fs::read_to_string(&path) { + Ok(source) => collect_dts_exports(&source), + Err(error) if error.kind() == ErrorKind::NotFound => BTreeSet::new(), + Err(error) => { + return Err(format!("Failed to read {}: {error}", path.display())); + } + }; + Ok((module.clone(), exports)) + }) + .collect::, String>>()?; + let canonical_declaration_owners = + collect_output_export_owners(output_dir, "d.ts", collect_dts_exports)?; + let declaration_owners = select_export_owners( + &declaration_exports_by_module, + &spec.modules, + &canonical_declaration_owners, + ); let bundled_source_bytes = modules.values().map(String::len).sum(); let mut js = String::new(); @@ -217,14 +421,26 @@ const __load = (id) => {\n\ let mut dts = String::from("// Generated by dynwinrt-codegen — do not edit\n"); for module in modules.keys() { - let names = export_owners + let runtime_names = export_owners .iter() .filter_map(|(name, owner)| (owner == module).then_some(name.as_str())) .collect::>(); - if !names.is_empty() { + if !runtime_names.is_empty() { dts.push_str(&format!( "export {{ {} }} from './{module}.js';\n", - names.join(", ") + runtime_names.join(", ") + )); + } + let type_only_names = declaration_owners + .iter() + .filter_map(|(name, owner)| { + (owner == module && !export_owners.contains_key(name)).then_some(name.as_str()) + }) + .collect::>(); + if !type_only_names.is_empty() { + dts.push_str(&format!( + "export type {{ {} }} from './{module}.js';\n", + type_only_names.join(", ") )); } } @@ -340,6 +556,181 @@ fn collect_cjs_exports(source: &str) -> BTreeSet { .collect() } +#[derive(Debug, PartialEq, Eq)] +enum TypeScriptToken { + Identifier(String), + Symbol(char), +} + +fn tokenize_typescript(source: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = source.char_indices().peekable(); + while let Some((_, character)) = chars.next() { + if character.is_whitespace() { + continue; + } + if character == '/' { + match chars.peek().map(|(_, next)| *next) { + Some('/') => { + chars.next(); + for (_, next) in chars.by_ref() { + if next == '\n' { + break; + } + } + continue; + } + Some('*') => { + chars.next(); + let mut previous = '\0'; + for (_, next) in chars.by_ref() { + if previous == '*' && next == '/' { + break; + } + previous = next; + } + continue; + } + _ => {} + } + } + if matches!(character, '\'' | '"' | '`') { + let quote = character; + let mut escaped = false; + for (_, next) in chars.by_ref() { + if escaped { + escaped = false; + } else if next == '\\' { + escaped = true; + } else if next == quote { + break; + } + } + continue; + } + if character.is_ascii_alphabetic() || matches!(character, '_' | '$') { + let mut identifier = String::from(character); + while let Some((_, next)) = chars.peek() { + if next.is_ascii_alphanumeric() || matches!(next, '_' | '$') { + identifier.push(*next); + chars.next(); + } else { + break; + } + } + tokens.push(TypeScriptToken::Identifier(identifier)); + } else { + tokens.push(TypeScriptToken::Symbol(character)); + } + } + tokens +} + +fn identifier_at(tokens: &[TypeScriptToken], index: usize) -> Option<&str> { + match tokens.get(index)? { + TypeScriptToken::Identifier(identifier) => Some(identifier), + TypeScriptToken::Symbol(_) => None, + } +} + +fn collect_export_list( + tokens: &[TypeScriptToken], + mut index: usize, + exports: &mut BTreeSet, +) -> usize { + while index < tokens.len() { + match tokens.get(index) { + Some(TypeScriptToken::Symbol('}')) => return index + 1, + Some(TypeScriptToken::Identifier(identifier)) => { + let mut exported = identifier.as_str(); + if identifier == "type" { + index += 1; + let Some(name) = identifier_at(tokens, index) else { + continue; + }; + exported = name; + } + if identifier_at(tokens, index + 1) == Some("as") { + if let Some(alias) = identifier_at(tokens, index + 2) { + exported = alias; + index += 2; + } + } + exports.insert(exported.to_string()); + } + _ => {} + } + index += 1; + } + index +} + +fn collect_dts_exports(source: &str) -> BTreeSet { + let tokens = tokenize_typescript(source); + let mut exports = BTreeSet::new(); + let mut index = 0; + let mut brace_depth = 0usize; + while index < tokens.len() { + match tokens.get(index) { + Some(TypeScriptToken::Symbol('{')) => { + brace_depth += 1; + index += 1; + } + Some(TypeScriptToken::Symbol('}')) => { + brace_depth = brace_depth.saturating_sub(1); + index += 1; + } + Some(TypeScriptToken::Identifier(keyword)) + if brace_depth == 0 && keyword == "export" => + { + index += 1; + while matches!(identifier_at(&tokens, index), Some("declare" | "abstract")) { + index += 1; + } + if identifier_at(&tokens, index) == Some("default") { + index += 1; + while matches!(identifier_at(&tokens, index), Some("declare" | "abstract")) { + index += 1; + } + } + if matches!(tokens.get(index), Some(TypeScriptToken::Symbol('{'))) { + index = collect_export_list(&tokens, index + 1, &mut exports); + continue; + } + let Some(kind) = identifier_at(&tokens, index) else { + continue; + }; + index += 1; + if kind == "type" && matches!(tokens.get(index), Some(TypeScriptToken::Symbol('{'))) + { + index = collect_export_list(&tokens, index + 1, &mut exports); + continue; + } + if matches!( + kind, + "class" + | "const" + | "enum" + | "function" + | "interface" + | "let" + | "module" + | "namespace" + | "type" + | "var" + ) && let Some(name) = identifier_at(&tokens, index) + { + exports.insert(name.to_string()); + } + } + _ => { + index += 1; + } + } + } + exports +} + #[cfg(test)] mod tests { use super::*; @@ -351,6 +742,26 @@ mod tests { assert_eq!(spec.modules, ["A", "B"]); } + #[test] + fn bundle_spec_rejects_portable_name_collisions() { + let error = parse_binding_bundle_spec("first=A,a").unwrap_err(); + assert!(error.contains("collide case-insensitively")); + for name in ["INDEX", "Lifetime", "PrOxY", "COM"] { + assert!( + parse_binding_bundle_spec(&format!("{name}=A")) + .unwrap_err() + .contains("Reserved bundle name") + ); + } + for module in ["INDEX", "Lifetime"] { + assert!( + parse_binding_bundle_spec(&format!("first={module}")) + .unwrap_err() + .contains("Reserved bundle module") + ); + } + } + #[test] fn require_scanner_keeps_only_flat_generated_siblings() { let source = "\ @@ -362,4 +773,26 @@ const nested = require('./nested/B.js');\n"; BTreeSet::from(["A".to_string()]) ); } + + #[test] + fn declaration_export_scanner_keeps_value_and_type_exports() { + let source = "\ +// export interface Ignored {}\n\ +export interface Point { x: number; }\n\ +export type Rect = { width: number };\n\ +export declare class Widget {}\n\ +export declare function createWidget(): Widget;\n\ +export { External, type ExternalShape as Shape } from './External.js';\n"; + assert_eq!( + collect_dts_exports(source), + BTreeSet::from([ + "External".to_string(), + "Point".to_string(), + "Rect".to_string(), + "Shape".to_string(), + "Widget".to_string(), + "createWidget".to_string(), + ]) + ); + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs index eb277246..93e9058e 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/generator.rs @@ -226,6 +226,23 @@ pub fn esm_index_to_cjs_getter_with_overrides( out } +pub fn collect_index_export_owners(esm_index: &str) -> BTreeMap { + let mut owners = BTreeMap::new(); + for line in esm_index.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("//") { + continue; + } + let Some((names, module)) = parse_index_export(trimmed) else { + continue; + }; + for name in names { + owners.insert(name, module.clone()); + } + } + owners +} + #[cfg(test)] mod bundle_override_tests { use super::*; @@ -240,6 +257,21 @@ export { TextBlock } from './TextBlock.js';\n"; assert!(output.contains("__exportLazy('Button', './first-screen.js');")); assert!(output.contains("__exportLazy('TextBlock', './TextBlock.js');")); } + + #[test] + fn index_export_owners_use_rendered_canonical_modules() { + let index = "\ +export { IPropertyValue, Point } from './IPropertyValue.js';\n\ +export { PropertyValue } from './PropertyValue.js';\n"; + assert_eq!( + collect_index_export_owners(index), + BTreeMap::from([ + ("IPropertyValue".into(), "IPropertyValue".into()), + ("Point".into(), "IPropertyValue".into()), + ("PropertyValue".into(), "PropertyValue".into()), + ]) + ); + } } /// Emit the ESM barrel. Re-exports are *eager* — the barrel loads every diff --git a/tools/dynwinrt-codegen/src/main.rs b/tools/dynwinrt-codegen/src/main.rs index d80c51e2..fb97f23c 100644 --- a/tools/dynwinrt-codegen/src/main.rs +++ b/tools/dynwinrt-codegen/src/main.rs @@ -16,7 +16,7 @@ use dynwinrt_codegen::codegen::winrt::extensions::winui; use dynwinrt_codegen::codegen::winrt::javascript::bundle::{ BindingBundleSpec, GeneratedBindingBundle, binding_bundle_redirect, binding_output_file_path, collect_binding_bundle_modules, generate_binding_bundle_with_modules, - parse_binding_bundle_spec, validate_binding_file_stem, + parse_binding_bundle_spec, portable_binding_name_key, validate_binding_file_stem, }; use dynwinrt_codegen::codegen::{project, render_dts, render_js}; use dynwinrt_codegen::com_metadata; @@ -257,13 +257,17 @@ fn parse_class_requests( } fn parse_binding_bundle_specs(values: &[String]) -> Result, String> { - let mut names = HashSet::new(); + let mut names = HashMap::::new(); values .iter() .map(|value| { let spec = parse_binding_bundle_spec(value)?; - if !names.insert(spec.name.clone()) { - return Err(format!("Duplicate --bundle name `{}`", spec.name)); + let key = portable_binding_name_key(&spec.name); + if let Some(existing) = names.insert(key, spec.name.clone()) { + return Err(format!( + "Duplicate --bundle name `{}`; it collides case-insensitively with `{existing}`", + spec.name, + )); } Ok(spec) }) @@ -1358,7 +1362,8 @@ fn write_js_barrel_and_manifest( // exports that were filtered out (for example ref-only WinUI controls such // as CompositionTarget). let index_content = render_index_from_existing_js_files(output_dir)?; - let (bundle_overrides, prepared_bundles) = prepare_binding_bundles(output_dir, bundles)?; + let (bundle_overrides, prepared_bundles) = + prepare_binding_bundles(output_dir, bundles, &index_content)?; write_binding_bundles(output_dir, &prepared_bundles)?; let js_content = @@ -1483,17 +1488,20 @@ fn preflight_binding_bundles( artifacts.into_iter().collect::>().join(", "), )); } + let existing_stems = collect_portable_binding_output_stems(output_dir)?; for bundle in bundles { let js_path = binding_output_file_path(output_dir, &bundle.name, "js", "bundle output name")?; let dts_path = binding_output_file_path(output_dir, &bundle.name, "d.ts", "bundle output name")?; - if js_path.exists() || dts_path.exists() { + if let Some(existing) = existing_stems.get(&portable_binding_name_key(&bundle.name)) { return Err(format!( - "Bundle name `{}` collides with an existing generated module", - bundle.name + "Bundle name `{}` collides with an existing generated module `{existing}` \ + (case-insensitive match)", + bundle.name, )); } + debug_assert!(!js_path.exists() && !dts_path.exists()); let missing_roots = bundle .modules .iter() @@ -1517,6 +1525,55 @@ fn preflight_binding_bundles( Ok(()) } +fn collect_portable_binding_output_stems( + output_dir: &Path, +) -> Result, String> { + let mut stems = BTreeMap::::new(); + let entries = fs::read_dir(output_dir).map_err(|error| { + format!( + "Failed to inspect binding output directory {}: {error}", + output_dir.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to inspect an entry in binding output directory {}: {error}", + output_dir.display() + ) + })?; + if !entry + .file_type() + .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))? + .is_file() + { + continue; + } + let Some(file_name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let stem = file_name + .strip_suffix(".d.ts") + .or_else(|| file_name.strip_suffix(".js")); + let Some(stem) = stem else { + continue; + }; + let key = portable_binding_name_key(stem); + if let Some(existing) = stems.get(&key) { + if existing != stem { + return Err(format!( + "Generated module names `{existing}` and `{stem}` collide \ + case-insensitively in {}", + output_dir.display() + )); + } + } else { + stems.insert(key, stem.to_string()); + } + } + Ok(stems) +} + fn ensure_unbundled_js_generation_output(output_dir: &Path) -> Result<(), String> { let artifacts = collect_existing_binding_bundle_artifacts(output_dir)?; if !artifacts.is_empty() { @@ -1598,6 +1655,7 @@ fn collect_existing_binding_bundle_artifacts( fn prepare_binding_bundles( output_dir: &Path, bundles: &[BindingBundleSpec], + index_content: &str, ) -> Result< ( BTreeMap, @@ -1607,6 +1665,7 @@ fn prepare_binding_bundles( > { let mut overrides = BTreeMap::new(); let mut generated_bundles = Vec::new(); + let canonical_export_owners = typescript::collect_index_export_owners(index_content); let closures = bundles .iter() .map(|bundle| { @@ -1614,35 +1673,39 @@ fn prepare_binding_bundles( }) .collect::, _>>()?; - let mut explicit_owners = BTreeMap::::new(); + let mut explicit_owners = BTreeMap::::new(); for bundle in bundles { for module in &bundle.modules { - if let Some(existing) = explicit_owners.insert(module.clone(), bundle.name.clone()) { + let key = portable_binding_name_key(module); + if let Some((existing_module, existing_bundle)) = + explicit_owners.insert(key, (module.clone(), bundle.name.clone())) + { return Err(format!( - "Configured root module `{module}` belongs to both bundle `{existing}` and `{}`", + "Configured root module `{module}` collides case-insensitively with \ + `{existing_module}` and belongs to both bundle `{existing_bundle}` and `{}`", bundle.name, )); } } } - let mut closure_memberships = BTreeMap::>::new(); + let mut closure_memberships = BTreeMap::>::new(); for (bundle, modules) in &closures { for module in modules { closure_memberships - .entry(module.clone()) + .entry(portable_binding_name_key(module)) .or_default() - .push(bundle.name.clone()); + .insert(bundle.name.clone()); } } let module_owners = closure_memberships .into_iter() - .filter_map(|(module, owners)| { - if let Some(explicit_owner) = explicit_owners.get(&module) { - Some((module, explicit_owner.clone())) + .filter_map(|(module_key, owners)| { + if let Some((_, explicit_owner)) = explicit_owners.get(&module_key) { + Some((module_key, explicit_owner.clone())) } else if owners.len() == 1 { - Some((module, owners[0].clone())) + Some((module_key, owners.into_iter().next().unwrap())) } else { None } @@ -1652,16 +1715,19 @@ fn prepare_binding_bundles( for (bundle, closure) in closures { let included_modules = closure .into_iter() - .filter(|module| module_owners.get(module) == Some(&bundle.name)) + .filter(|module| { + module_owners.get(&portable_binding_name_key(module)) == Some(&bundle.name) + }) .collect::>(); let generated = generate_binding_bundle_with_modules(output_dir, bundle, &included_modules)?; for export in &generated.exports { - if let Some(existing) = overrides.insert(export.clone(), bundle.name.clone()) { - return Err(format!( - "Binding export `{export}` is configured in both bundle `{existing}` and `{}`", - bundle.name - )); + let Some(canonical_module) = canonical_export_owners.get(export) else { + continue; + }; + if module_owners.get(&portable_binding_name_key(canonical_module)) == Some(&bundle.name) + { + overrides.insert(export.clone(), bundle.name.clone()); } } generated_bundles.push((bundle.name.clone(), generated)); diff --git a/tools/dynwinrt-codegen/tests/binding_bundle_test.rs b/tools/dynwinrt-codegen/tests/binding_bundle_test.rs index 01723e85..f1b6768e 100644 --- a/tools/dynwinrt-codegen/tests/binding_bundle_test.rs +++ b/tools/dynwinrt-codegen/tests/binding_bundle_test.rs @@ -65,6 +65,8 @@ exports.count = () => tracked.size;\n", "\ export declare const AName: string;\n\ export declare class A {}\n\ +export interface Point { x: number; y: number; }\n\ +export type Rect = { x: number; y: number; width: number; height: number };\n\ export declare const APeer: () => string;\n\ export declare const Track: (value: object) => object;\n\ export declare const Separator: string;\n", @@ -102,9 +104,19 @@ export declare const BPeer: () => string;\n", assert!(generated.exports.contains("track")); assert!(generated.dts.contains("from './A.js';")); assert!(generated.dts.contains("from './B.js';")); + assert!( + generated + .dts + .contains("export type { Point, Rect } from './A.js';") + ); fs::write(directory.join("first-screen.js"), generated.js).unwrap(); fs::write(directory.join("first-screen.d.ts"), generated.dts).unwrap(); + assert!( + fs::read_to_string(directory.join("A.d.ts")) + .unwrap() + .contains("export interface Point") + ); for module in &generated.modules { fs::write( directory.join(format!("{module}.js")), @@ -168,6 +180,40 @@ assert.equal(AName, DeepAName);\n", ); } +#[test] +fn bundle_rejects_case_insensitive_names_and_fixed_entrypoints() { + let directory = test_directory("binding-bundle-portable-names"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("A.js"), "exports.A = 'A';\n").unwrap(); + + let duplicate = run_bundles(&directory, &["First=A", "first=A"]); + let stderr = String::from_utf8_lossy(&duplicate.stderr); + assert!(!duplicate.status.success()); + assert!(stderr.contains("case-insensitively")); + assert!(!directory.join("First.js").exists()); + assert!(!directory.join("first.js").exists()); + + let module_collision = run_bundle(&directory, "a=A"); + let stderr = String::from_utf8_lossy(&module_collision.stderr); + assert!(!module_collision.status.success()); + assert!(stderr.contains("collides with an existing generated module `A`")); + assert!(!directory.join("a.d.ts").exists()); + + let module_casing = run_bundle(&directory, "first=a"); + let stderr = String::from_utf8_lossy(&module_casing.stderr); + assert!(!module_casing.status.success()); + assert!(stderr.contains("module `a` with non-portable casing")); + assert!(!directory.join("first.js").exists()); + + let reserved = run_bundle(&directory, "PrOxY=A"); + let stderr = String::from_utf8_lossy(&reserved.stderr); + assert!(!reserved.status.success()); + assert!(stderr.contains("Reserved bundle name")); + assert!(!directory.join("PrOxY.js").exists()); + fs::remove_dir_all(directory).unwrap(); +} + #[test] fn bundle_rerun_rejects_stale_existing_artifacts() { let directory = test_directory("binding-bundle-stale-rerun"); @@ -351,3 +397,130 @@ assert.strictEqual(require('./B.js').B, second.B);\n", ); fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn multiple_bundles_use_the_ordinary_barrel_canonical_export_owner() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping canonical bundle owner test: node is unavailable"); + return; + } + + let directory = test_directory("binding-bundle-canonical-owner"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("IPropertyValue.js"), + "\ +exports.IPropertyValue = class IPropertyValue {};\n\ +exports.Point = { owner: 'IPropertyValue' };\n", + ) + .unwrap(); + fs::write( + directory.join("PropertyValue.js"), + "\ +exports.PropertyValue = class PropertyValue {};\n\ +exports.Point = { owner: 'PropertyValue' };\n", + ) + .unwrap(); + + let output = run_bundles( + &directory, + &["property=PropertyValue", "interface=IPropertyValue"], + ); + assert!( + output.status.success(), + "multi-bundle generation failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let root = fs::read_to_string(directory.join("index.js")).unwrap(); + assert!(root.contains("__exportLazy('Point', './interface.js');")); + assert!(!root.contains("__exportLazy('Point', './property.js');")); + + fs::write( + directory.join("canonical-owner-test.js"), + "\ +const assert = require('node:assert/strict');\n\ +const root = require('./index.js');\n\ +const interfaceBundle = require('./interface.js');\n\ +const propertyBundle = require('./property.js');\n\ +const deepInterface = require('./IPropertyValue.js');\n\ +const deepProperty = require('./PropertyValue.js');\n\ +assert.strictEqual(root.Point, interfaceBundle.Point);\n\ +assert.notStrictEqual(root.Point, propertyBundle.Point);\n\ +assert.strictEqual(deepInterface.Point, interfaceBundle.Point);\n\ +assert.strictEqual(deepProperty.Point, propertyBundle.Point);\n", + ) + .unwrap(); + let node = Command::new("node") + .arg("canonical-owner-test.js") + .current_dir(&directory) + .output() + .unwrap(); + assert!( + node.status.success(), + "canonical owner runtime failed:\n{}", + String::from_utf8_lossy(&node.stderr) + ); + fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn bundle_uses_canonical_dependency_owner_for_duplicate_exports() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping canonical dependency owner test: node is unavailable"); + return; + } + + let directory = test_directory("binding-bundle-canonical-dependency-owner"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join("AAux.js"), + "exports.Helper = { owner: 'AAux' };\n", + ) + .unwrap(); + fs::write( + directory.join("B.js"), + "\ +require('./AAux.js');\n\ +exports.B = class B {};\n\ +exports.Helper = { owner: 'B' };\n", + ) + .unwrap(); + + let output = run_bundle(&directory, "combo=B"); + assert!( + output.status.success(), + "bundle generation failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let root = fs::read_to_string(directory.join("index.js")).unwrap(); + let bundle = fs::read_to_string(directory.join("combo.js")).unwrap(); + assert!(root.contains("__exportLazy('Helper', './combo.js');")); + assert!(bundle.contains("get: () => __load('./AAux.js').Helper")); + + fs::write( + directory.join("canonical-dependency-test.js"), + "\ +const assert = require('node:assert/strict');\n\ +const root = require('./index.js');\n\ +const combo = require('./combo.js');\n\ +const deepAux = require('./AAux.js');\n\ +const deepB = require('./B.js');\n\ +assert.strictEqual(root.Helper, combo.Helper);\n\ +assert.strictEqual(root.Helper, deepAux.Helper);\n\ +assert.notStrictEqual(root.Helper, deepB.Helper);\n", + ) + .unwrap(); + let node = Command::new("node") + .arg("canonical-dependency-test.js") + .current_dir(&directory) + .output() + .unwrap(); + assert!( + node.status.success(), + "canonical dependency runtime failed:\n{}", + String::from_utf8_lossy(&node.stderr) + ); + fs::remove_dir_all(directory).unwrap(); +} From 48a3ffe0fc03a569a2b25718682fd4315ee2e8fb Mon Sep 17 00:00:00 2001 From: "Leilei Zhang (from Dev Box)" Date: Tue, 11 Aug 2026 16:38:33 +0800 Subject: [PATCH 3/3] Fix Node 22 bundle named exports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 84688dea-d965-4933-a2d9-41f8f6ad2f2e --- .../src/codegen/winrt/javascript/bundle.rs | 8 +++++++- .../dynwinrt-codegen/tests/binding_bundle_test.rs | 14 +++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs index 6a4c0f16..a56627eb 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/bundle.rs @@ -413,9 +413,15 @@ const __load = (id) => {\n\ js.push_str( "Object.defineProperty(exports, '__dynwinrtLoadBundledModule', { value: __load });\n", ); + // Keep the lazy getters behind a helper. Node 22's CommonJS lexer drops a + // named export when it sees a direct Object.defineProperty getter for the + // same name, even if a static exports.Name assignment is also present. + js.push_str( + "const __defineBundleExport = (name, get) => Object.defineProperty(exports, name, { enumerable: true, configurable: true, get });\n", + ); for (export, owner) in &export_owners { js.push_str(&format!( - "exports.{export} = undefined;\nObject.defineProperty(exports, '{export}', {{ enumerable: true, configurable: true, get: () => __load('./{owner}.js').{export} }});\n", + "exports.{export} = undefined;\n__defineBundleExport('{export}', () => __load('./{owner}.js').{export});\n", )); } diff --git a/tools/dynwinrt-codegen/tests/binding_bundle_test.rs b/tools/dynwinrt-codegen/tests/binding_bundle_test.rs index f1b6768e..e8698361 100644 --- a/tools/dynwinrt-codegen/tests/binding_bundle_test.rs +++ b/tools/dynwinrt-codegen/tests/binding_bundle_test.rs @@ -99,8 +99,16 @@ export declare const BPeer: () => string;\n", .unwrap(); assert_eq!(generated.module_count, 3); assert!(generated.js.contains("require('node:path')")); - assert!(generated.js.contains("get: () => __load('./A.js').AName",)); - assert!(generated.js.contains("get: () => __load('./B.js').BName",)); + assert!( + generated + .js + .contains("__defineBundleExport('AName', () => __load('./A.js').AName);") + ); + assert!( + generated + .js + .contains("__defineBundleExport('BName', () => __load('./B.js').BName);") + ); assert!(generated.exports.contains("track")); assert!(generated.dts.contains("from './A.js';")); assert!(generated.dts.contains("from './B.js';")); @@ -497,7 +505,7 @@ exports.Helper = { owner: 'B' };\n", let root = fs::read_to_string(directory.join("index.js")).unwrap(); let bundle = fs::read_to_string(directory.join("combo.js")).unwrap(); assert!(root.contains("__exportLazy('Helper', './combo.js');")); - assert!(bundle.contains("get: () => __load('./AAux.js').Helper")); + assert!(bundle.contains("__defineBundleExport('Helper', () => __load('./AAux.js').Helper);")); fs::write( directory.join("canonical-dependency-test.js"),