W-23599705: Add external DataWeave module support to Node.js binding - #154
Merged
Conversation
Add ResolveModuleCallback function pointer interface and CallbackWeaveResourceResolver implementation that delegates to C callback. Foundation for external module support in Node.js/Python bindings. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Move path variable outside try block to reuse in catch block error message. Avoids extra work and potential failure if exception was thrown during conversion. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add setResolver() method to install callback-based module resolver. Engine rebuilt with CompositeWeaveResourceResolver(ClassLoader, Callback) to preserve built-ins while adding user modules. One resolver per process. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical fixes: - Add synchronized to setResolver() and volatile to resolver/engine fields - Fix field ordering: move static resolver before instance engine - Add synchronized block when rebuilding engine to prevent race conditions - Copy volatile resolver to local var in compositeResolver() for safe access Important fixes: - Use ParserConfigurationBuilder().build() to match native-cli pattern - Remove incorrect reference to nonexistent composeResolvers() method - Add javadoc about thread-safe callback requirement - Update constructor comment for accuracy Minor improvements: - Remove unused Seq/HashMap imports - Improve warning messages Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add run_script_with_resolver, run_script_callback_with_resolver, and run_script_input_output_callback_with_resolver. Each accepts ResolveModuleCallback and installs resolver before delegating to existing execution logic. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement resolve_module_callback using napi_threadsafe_function pattern. Background thread blocks while main thread executes JS resolver, returns source string. Add runWithResolver N-API method. Matches ReadCallback pattern. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…p in resolver bridge Fix 4 Important issues from code review: 1. Memory leak: Free resolver result_source after native copies it - Added cleanup in napi_run_with_resolver after fn_run_script_with_resolver call - Prevents leak on every module resolution 2. Missing cleanup: Destroy resolver resources on module unload - Added g_resolver_data cleanup in napi_cleanup - Releases threadsafe function, destroys mutex/cond, frees memory 3. Race condition: Protect g_resolver_data initialization with mutex - Wrapped initialization check/alloc in g_mutex lock - Added error handling for malloc/threadsafe function creation failures 4. NULL checks: Validate all malloc/strdup returns - Added checks after script/inputs/mime_type allocations - Added check after strdup(module_path) in resolve_module_callback - Free partial allocations on error Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add comments clarifying that resolver is initialized once per process lifetime and subsequent calls with different callbacks will reuse the first resolver. This makes the singleton constraint explicit, matching the ScriptRuntime.setResolver() enforcement on the native side. Addresses code review finding #4 about implicit global resolver behavior. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement in-memory map-based resolver with ModuleResolver type. Foundation for resolver module. Returns null on miss, logs at debug level. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Remove console.debug() call to make resolver a pure function - Use 'in' operator for cleaner existence check - Add test case for empty string source (valid edge case) Addresses code review feedback on resolver purity. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement directory-based resolver with recursive namespace support. Reads from disk on every access (no cache). Throws on read errors, returns null on not-found. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Security fixes: - Add path traversal protection (resolve paths and validate within baseDir) - Replace error type casting with proper Error type guard Performance and robustness improvements: - Remove TOCTOU race by checking ENOENT in catch block instead of existsSync - Remove console.debug on cache misses (expected behavior, too verbose) - Proper handling of NodeJS.ErrnoException for ENOENT check Add test case for path traversal prevention. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add adm-zip dependency and implement JAR-based resolver. Extracts all .dwl files from JARs into in-memory map. Returns Promise (async extraction) but resolver itself is synchronous. Throws on invalid ZIP. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Use proper type guard for error handling to match modulesFromDirectory pattern. Prevents undefined in error messages when error lacks .message. Also add @types/adm-zip dev dependency for TypeScript support. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement composite resolver with fallback chain. Tries each resolver in order, returns first non-null. Completes resolver.ts module with all four factories: modulesFromMap, modulesFromDirectory, modulesFromJars, composeResolvers. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add runWithResolver function to ffi.ts that wraps the C addon's napi_run_with_resolver method. Import ModuleResolver type from resolver.ts. Binds basic script execution with module resolution callback. Note: Streaming variants (runStreamingWithResolver, runInputOutputWithResolver) were not implemented in Task 4's C addon and are deferred to future work. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add resolveModule to DataWeaveOptions. Route run() to runWithResolver when resolver present, standard run otherwise. Backward compatible (resolver is optional). Export resolver factories from main module. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Test 'throws when module not found' now passes raiseOnError:true, since run() only throws when explicitly requested (matches existing pattern elsewhere). Also document that the native layer installs only the first resolver registered per process; later DataWeave instances with a different resolveModule silently reuse it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…king verification
ScriptRuntime.setResolver() and CallbackWeaveResourceResolver's constructor
compared a GraalVM Word type (CFunctionPointer) to null with ==, which
native-image rejects at build time ("Should not compare Word to Object in
condition"). Use callback.isNull() instead, blocking all native builds that
exercise the resolver path.
The Node addon's run_script_with_resolver_fn typedef declared 5 parameters
(including an unused mimeType) while the actual @centrypoint signature takes
4, misaligning the call and corrupting the resolver callback function
pointer (observed as a SIGBUS). Fixed the typedef/call site to match.
The resolver callback bridge used napi_threadsafe_function, a pattern for a
background thread handing work to the JS main thread. runWithResolver's
native call is synchronous on the calling (JS) thread, so the callback fired
on that same thread and deadlocked waiting on its own queued work. Replaced
it with a direct napi_ref + napi_call_function bridge, and fixed a related
leak where only one resolver result buffer was tracked/freed per call even
though a single script can trigger multiple resolver invocations.
Finally, NameIdentifierHelper.toWeaveFilePath (Java) always renders module
paths with a leading separator (e.g. "/org/test/lib.dwl"), but the addon
forwarded this to JS unmodified. modulesFromDirectory tolerated it via
path.join, but modulesFromMap's exact-key lookup silently failed. Strip the
leading '/' in resolve_module_callback before invoking the JS resolver.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…solver Install a shared resolver on the TCK harness's DataWeave instance backed by modulesFromDirectory, pointing at a committed fixtures/ tree. The six cases below import org::mule::weave::v2::libs::lib, a module that lives only in the private data-weave runtime repo's test resources and isn't in any published artifact or TCK zip — add a minimal fixture reproducing just the parts these cases exercise (name binding, dw::Core re-export, a function) and remove the corresponding ignore-list entries: full-qualified-name-ref-out.json import-component-alias-lib-out.json import-lib-out.json import-lib-with-alias-out.json import-named-lib-out.json import-star-out.json Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
resolve_module_callback caught the JS resolver's exception and cleared it (correctly, to avoid leaking a pending exception into the next N-API call), but only logged a generic "Resolver callback threw exception" string — message and stack were discarded. The design spec's FFI Boundary Errors section requires logging the exception with its stack trace, and the read-callback bridge nearby already does this correctly. Mirror that same napi_get_named_property extraction of message/stack here so a thrown resolver error is actually diagnosable instead of silently swallowed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add external-modules.md with quick start, resolver factories, error handling, and JAR dependency management. Update main README with external modules section. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Address reviewer findings: 1. Correct File I/O Errors section: resolver exceptions are caught internally and logged to stderr only; result.error is the same generic "Unable to resolve module" message as module-not-found, not distinguishable via the result object. 2. Add explicit disclosure: module-level singleton functions cannot be configured with resolveModule; users must construct their own DataWeave instance. Clarify in both docs and README. 3. Fix heading structure: move External Modules under API Reference as subsection (###) instead of top-level section (##). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…vocation
ScriptRuntime's DataWeave engine is a process-wide singleton: once any run()
call installs a resolveModule callback, that same composite resolver (built-
ins + callback) is shared by every later execution path -- including
runStreaming()/runTransform(), whose native calls execute on a background
uv_thread (see addon.c's streaming_thread_fn/transform_thread_fn), not the JS
thread that registered the resolver. addon.c's resolve_module_callback had no
check that it was running on the thread that owns g_resolver_env/g_resolver_ref,
so a streamed/transformed script importing a non-built-in module could call
back into napi from the wrong OS thread -- undefined behavior, reproduced here
as a fatal V8 HandleScope crash.
Fix: record the OS thread that installs the resolver (g_resolver_thread, set
alongside g_resolver_env in napi_run_with_resolver) and check it at the top of
resolve_module_callback. Off-thread invocations now return NULL ("not found")
instead of touching napi, which matches the already-documented built-ins-only
fallback for streaming/transform but makes it safe instead of merely assumed.
Also in this pass:
- Regression test (dataweave-resolver.test.ts) that installs a resolver via
run() then exercises runStreaming() against a distinct, never-before-
resolved module path (DataWeave caches resolved modules by name at the
process-lifetime engine level, so reusing an already-resolved path would
silently skip the resolver chain and not exercise the guard). Verified this
test crashes the process before the addon.c guard and passes cleanly after.
- Trust-model documentation: resolveModule runs with full process permissions
and no sandboxing (same as the CLI resolving .dwl files from disk) --
documented in external-modules.md, README.md, and the DataWeaveOptions
JSDoc.
- Trip-wire comments on the unwired run_script_callback_with_resolver /
run_script_input_output_callback_with_resolver entrypoints (NativeLib.java
and their addon.c uv_dlsym lookups), explaining why they're intentionally
not used by runStreaming()/runTransform().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Windows NTFS ACLs don't respect Unix chmod 000 for denying read access, so fs.readFileSync succeeds where the test expects it to throw. Skip this platform-specific test on win32 to fix CI. Ref: https://github.com/mulesoft/data-weave-cli/actions/runs/31037261526/job/92412368635 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Use DWScriptingEngine.builder() instead of deprecated DataWeaveScriptingEngine constructor - Use DWModuleComponentsFactory.createSimpleDWModuleComponentsFactoryBuilder() for cleaner resolver setup - Extract createModuleComponentsFactory() helper to eliminate duplication between constructor and setResolver() - Clean up CallbackWeaveResourceResolver imports and formatting - Type-safe Scala empty Seq construction with explicit cast No behavioral changes — purely API modernization. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
mlischetti
force-pushed
the
nodejs-external-modules
branch
from
August 5, 2026 22:07
2514126 to
e8768a3
Compare
…upport Fixes the allocator mismatch (libc free() on a GraalVM-allocated result, addon.c) that could corrupt/crash the process, upgrades adm-zip to patch GHSA-xcpc-8h2w-3j85, closes a symlink-escape hole in modulesFromDirectory, stops resolver-error logging from leaking secrets to stderr by default (opt-in via DATAWEAVE_RESOLVER_DEBUG=1), fixes a prototype-chain lookup bug in modulesFromMap, includes docs/ in the published npm package so the README's external-modules link resolves, and cleans up leaking native references and a no-op multi-JAR test in the test suite. Also documents (and adds isolated-process regression coverage for) the process-wide first-resolver-wins behavior — the underlying single-isolate/single-resolver architecture is tracked separately in W-23692110.
Follow-up to the revalidation review of commit 4aac0d0: - The "File I/O Errors" section still said resolver failure details are always logged to stderr; that's now only true when the caller opts in via DATAWEAVE_RESOLVER_DEBUG=1 (addon.c). Update the text and debugging note accordingly. - Document that modulesFromDirectory's symlink-escape check closes the stable-symlink case but not a time-of-check/time-of-use race (no portable openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS) equivalent in Node), and that the module tree must not be writable by less-trusted principals. Process-wide resolver isolation (the other Medium finding from the revalidation) is intentionally left as-is: it's the root cause tracked in W-23692110, and a fail-loudly interim change was considered and declined for this PR.
…ad mutex reinit, correct docs Addresses the follow-on revalidation review of PR #154's external-module resolver support: - addon.c: guard the process-global g_mutex init with uv_once so loading the addon into multiple worker_threads Workers doesn't re-initialize an already-live mutex. - resolver.ts: modulesFromDirectory() now builds candidate paths from the captured absolute base instead of re-resolving the original (possibly relative) baseDir on every call, so a later process.chdir() no longer breaks lookups. Containment checks switch from a startsWith(base + sep) string check to a path.relative()-based isContained() helper, fixing a root-directory baseDir (e.g. "/") rejecting every child path. - first-resolver-wins fixture/test: the fixture now also proves the first resolver stays active on the second instance (not just that the second instance's own resolver lost), cleans up both instances via try/finally, and the isolated child-process test gets a timeout so a native deadlock fails the test instead of hanging the suite (same fix applied to init-bad-path.test.ts, which had the identical gap). - Docs + JSDoc: correct "Multiple Resolvers in One Process" to say the first resolver-backed run() wins (not the first initialize(), which only loads/ref-counts the library), and add an explicit warning that running resolver-backed calls concurrently across Workers is memory-unsafe, not just unsupported. The two Critical findings from that review (a resolver-buffer free race and a resolver N-API reference that can outlive its owning Worker environment) are confined to the same process-wide resolver architecture already deferred to W-23692110 and are not addressed here; the Worker memory-safety warning above documents that limitation explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
andres-rad
approved these changes
Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds support for resolving external DataWeave modules (not compiled into the native image) from the Node.js binding (
@dataweave/native), via aresolveModulecallback bridged through the C N-API addon down to the JavaWeaveResourceResolverSPI.resolveModuleoption onDataWeaveOptions, consumed bynew DataWeave({ resolveModule })modulesFromMap,modulesFromDirectory,modulesFromJars(async),composeResolvers(first-match-wins fallback chain)ScriptRuntime,CallbackWeaveResourceResolver, new@CEntryPoints) and the C addon (resolve_module_callback,napi_run_with_resolver)modulesFromDirectorynative-lib/node/docs/external-modules.md(quick start, resolver factories, error handling, trust model) plus a README sectionKey constraints and known limitations (documented)
DataWeaveinstance with a different resolver logs a warning and keeps the first. Workaround:composeResolvers(). The root cause (hard-singletonScriptRuntime+ single-isolate Node addon) is tracked separately for an architectural fix in W-23692110; this PR documents the limitation and adds isolated-process regression coverage for the current behavior, but does not change the underlying design.async/awaitinside the callback itself; async setup likemodulesFromJarshappens before construction).runStreaming()/runTransform()do not honorresolveModule— they execute on a background thread, and wiring a resolver callback there would be an N-API thread-affinity violation (see below). This is called out explicitly in the docs.external-modules.md, the README, andDataWeaveOptions.resolveModuleJSDoc.Process notes
Built via subagent-driven development: 12 planned tasks, each implemented and independently reviewed with a fix-loop, followed by a final whole-branch review. That final review caught a Critical N-API thread-affinity hazard —
resolve_module_callbackinaddon.cwas reachable from the background threadrunStreaming/runTransformspawn (since they share the same singleton engine once a resolver is installed by any priorrun()call), and would call back into anapi_env/napi_refcaptured on a different OS thread — undefined behavior, not the documented clean fallback. Fixed with a thread-identity guard (uv_thread_self()/uv_thread_equal()) that fails closed instead of crashing, plus a regression test and trip-wire comments on the two intentionally-unwired resolver entrypoints for streaming/transform.Code-review remediation (two independent reviews)
napi_run_with_resolverfreed a GraalVMUnmanagedMemory-allocated result with libcfree()instead offn_free_cstring. Fixed tostrdup+fn_free_cstringwhile the thread is still attached, matching the pattern used elsewhere inaddon.c.adm-zipbumped^0.5.10→^0.6.0(patches GHSA-xcpc-8h2w-3j85, a DoS via crafted archive);npm audit --omit=devis clean.modulesFromDirectorynow canonicalizes both the base directory and each candidate path (fs.realpathSync) and re-checks containment against the canonical paths, closing a gap where an in-tree symlink could resolve outsidebaseDir. Covered by a new regression test.addon.cnow log a fixed, content-free diagnostic by default; the previous verbosemessage/stacklogging (which could include module source, credentials, or paths) is opt-in viaDATAWEAVE_RESOLVER_DEBUG=1.docs/topackage.json'sfiles, so the README's link todocs/external-modules.mdresolves in the published tarball (verified vianpm pack --dry-run).modulesFromMapnow usesObject.hasOwninstead ofin(avoids matching inherited properties liketoString); the multi-JAR unit test now uses two distinct archives and asserts both resolve; integration tests now track and clean up every explicitDataWeaveinstance instead of leaking native references.Test plan
native-lib/node:npm test— 844 passed, 59 skipped (unit + integration + TCK), 0 failed./gradlew native-lib:test native-cli:test— BUILD SUCCESSFUL, all tests greennpm audit --omit=dev— 0 vulnerabilitiesnpm pack --dry-run— tarball includesdocs/external-modules.md🤖 Generated with Claude Code