Compatibility

Does Obfuscation Break Package Manifests and Module Resolution?

Reading your own package manifest at run time is so ordinary that it barely registers as an integration: the version in a crash report, the dependency list in a diagnostics command, the entry point a build script writes into a distributable. All of it goes through property names that npm, your tooling and node's own module resolver decided, which puts every one of them on the far side of a boundary.

What was measured, on both sides of the manifest

One sample driven through five protection profiles for the base column, and through both member-renaming profiles for everything after. It reads a manifest off disk and prints the values a command-line tool prints: version, name, entry point, module type, licence, executable names, dependency count, build script presence, engine range and export subpaths. Then it does the other half: it builds a manifest object, writes it into a directory beside a generated entry file, and asks node to resolve that directory as a package.

That second half is the reason this is a separate measurement rather than a footnote. The read side has been covered before -- a manifest parsed from text keeps its keys and the code reading it does not. The write side puts node's own resolver on the far end of the contract, and node's resolver is a reader you cannot patch, ship alongside, or configure.

The base column is clean. All five profiles reproduced the unprotected output exactly, including the resolution result. Protecting a tool that reads or writes manifests with a default configuration changes nothing.

Node cannot find an entry point your build just wrote

With a pattern matching the entry-point key, the generated manifest carried a generated identifier where that key belongs, and the resolution attempt went from succeeding to failing with a module-not-found error. The entry file was written correctly, in the right directory, with the right content. The manifest declaring it was the part that changed.

This is a build-time failure that surfaces at install time or at first import, which is about the worst distance possible between cause and symptom. The package is on disk, the file it should point at is on disk, and the manifest is valid JSON that no linter objects to -- it simply names a key node does not look for, so node falls back to its default entry file and finds nothing.

The same arm broke the read side in one line: the entry point printed from the parsed manifest read as undefined. Anything that resolves paths relative to the declared entry -- a bundler config, a documentation generator, a packaging script -- inherits that as an empty path rather than an error.

A related arm renamed the module-type key. The generated manifest lost its declaration of module format. Where that declaration is what tells node whether to treat a file as an ES module or a CommonJS one, the interpretation of every file in the package can change without a single line of that code being touched.

A version that is undefined everywhere it matters

The version key was measured on its own, because it appears in more places than any other manifest field. Renaming it produced an undefined version line, an undefined major number, and an update check that flipped from reporting an upgrade was needed to reporting that none was.

That last one is the interesting half. The comparison is arithmetic on a value that is now undefined, and any relational comparison involving undefined is false, so the branch that says an upgrade is available is never taken. The tool does not report an unknown version; it reports that it is current. This is the same shape as an expiry check that never expires and a threshold that stops being crossed.

The crash report in the same run lost the version field entirely rather than carrying a generated one, because a property whose value is undefined is omitted during serialisation. Every error your build tool reports arrives at the tracker with no release attached, which quietly destroys the ability to say when a regression started.

Blocks that go quietly empty

Manifests are mostly nested blocks, and nested blocks are read through guards. A pattern matching the executables, scripts, engines and exports keys produced an empty executable list, a build-script check that returned false, an unset engine range and an empty subpath list -- with no error anywhere, because each of those reads falls back to an empty object when the key is missing.

The dependency block behaves the same way. Renaming it took the dependency count from three to zero and made a specific dependency report as absent. Any diagnostics command, licence report, security summary or support bundle built from that data now describes a package with no dependencies, which is the kind of output people trust precisely because nobody expects it to be wrong.

This is the qualifier that keeps coming up: renaming a container fails loudly only when the read is unguarded. Every guarded read reports the empty case, which is indistinguishable from a manifest that genuinely omits the block.

Code your build writes as text keeps its names

One arm is worth stating on its own, because it catches a habit rather than an API. Build scripts routinely emit code as strings -- a shim, an entry file, a wrapper, a stub. The sample writes an entry module as a string literal and then reads a property off it after requiring it back.

With a pattern matching that property name, the string was untouched and the read was renamed, so the require succeeded and the check reported the wrong module. The emitted file still declares the original name, because a string literal is not an identifier and nothing in it is a rename site. Only the code reading it moved.

The generalisation is short: anything your build writes as text keeps its names, and anything your build compiles gets renamed. Templates, generated migrations, emitted worker code, inline scripts written into HTML, and code fragments assembled at run time are all on the text side of that line, while every reader of them is on the other.

The read side, and why it is the easier half

Reading a manifest has been covered on this site as part of configuration files, and the mechanism is identical: the file is parsed from text, so the parsed object keeps its keys, and only the code reading it moves. What is worth adding here is how differently the two halves behave once they fail.

A wrong read is local. The licence field read as undefined in one arm, and the effect is a line in a diagnostics output, an about box, or a compliance report. Somebody eventually notices, because a human reads that output and knows what should be in it.

A wrong write is remote. The manifest is correct JSON, it is on disk, it installs, and the failure happens inside a resolver in somebody else's process at a time you are not watching. Nothing in your build reported a problem, because from the build's point of view nothing went wrong: it wrote the object it was asked to write.

That asymmetry is why the generated-manifest arm was worth building rather than assuming. Both halves come from the same renamed name, and only one of them produces evidence anywhere near the cause.

What this means in practice

Treat manifest keys as external names. They were defined by npm and by node's resolution algorithm, and both readers ship on the user's machine rather than in your bundle. Put them in the reserved set or, better, anchor the member pattern to a prefix that manifest handling never uses.

Pay particular attention to build steps that generate manifests or generate code. The read side fails in ways somebody eventually notices -- an undefined version in a log. The write side fails at a distance, in a package that installs cleanly and cannot be imported, and the error names a file rather than a build setting.

Verify against the protected artifact by round-tripping one package: write it, resolve it, and print the property you expect. In the measured run the difference between a working build and a broken one was exactly that one line, and every earlier line looked correct.

If you also read configuration from files on disk, the same rule covers both and the reasoning is identical, which is why the two are worth reviewing in one pass rather than separately.

Frequently asked questions

Does obfuscation break package.json handling?

Not in the default configuration. A sample that reads a manifest, prints the values a command-line tool prints, then writes a manifest and asks node to resolve it produced identical output on all five protection profiles measured. Manifests become a surface when member renaming matches the keys npm and node's resolver read.

Why can node not resolve a package my build just generated?

Because the manifest names a generated key where the entry-point key belongs. The entry file is written correctly and the manifest is valid JSON, but node looks for the key it knows, does not find it, falls back to its default entry file and fails with a module-not-found error. The measured run went from resolving successfully to exactly that.

Why is my application version undefined after protection?

Because the version is read off a manifest parsed from text while the code reading it was renamed. In the measured run the version line and the major number both read as undefined, and the update check flipped from reporting an upgrade available to reporting none, since a comparison involving undefined is false.

Why do my crash reports have no version attached?

Because a property whose value is undefined is omitted when the report is serialised, so the field is absent rather than wrong. The measured report lost its version field entirely while the application name beside it stayed correct, which makes it impossible to say which release a regression started in.

Why does my tool report zero dependencies?

Because the dependency block is read through a guard that substitutes an empty object when the key is missing, and renaming the key triggers that guard. The count went from three to zero with no error. Every nested manifest block behaves this way, so executables, scripts, engines and exports all report empty rather than failing.

Does code my build writes as a string get renamed too?

No, and that asymmetry was measured. A module emitted as a string literal kept its original property name while the code reading that property was renamed, so the require succeeded and returned something the check reported as the wrong module. Anything written as text keeps its names; anything compiled gets renamed.

How should a protected build tool handle manifests?

Reserve the manifest keys or anchor the member pattern to a prefix manifest handling never uses, then verify by round-tripping one generated package against the protected build: write it, resolve it, and print the property you expect. That single line was the difference between a working and a broken build in the measurement, and every line before it looked correct.

Related reading