Compatibility

Does Obfuscation Break Plugin and Extension APIs?

Most compatibility questions about obfuscation are about a boundary between your code and a runtime, a file or a server. A plugin API is stranger than that. The far side is JavaScript, running in the same process, in the same heap, written by somebody who does not have your build and will never run your protection settings. That makes the property names on your plugin contract the most exposed names in the whole application.

What was measured, with a plugin nobody protected

One host file driven through five protection profiles for the base column, and through both member-renaming profiles for everything after that. The host loads a plugin module, validates it the way every plugin host does, builds a context object, calls the plugin's setup function, collects the commands and listeners the plugin registers, and finally calls a hook the plugin exported.

The plugin module itself is never protected by the runner, and that is deliberate. It stands in for a module installed from a registry or dropped into a plugins directory. Its property names are fixed text as far as your build is concerned, exactly like a column name or a webhook field, and it reads the property names of the context object you hand it. Both directions of one contract are therefore visible in a single run.

The base column is clean. On the default target, the modern target, the gate profile, the modern gate profile and the string-transform profile the host reproduced the unprotected output line for line: the validation verdict, the plugin name and API version, the compatibility check, the registered commands and listeners, the hook table and the hook result. Protecting an extensible application with a default configuration does not disturb its plugin layer.

A control arm confirms the sample is measuring what it claims to. A pattern matching an option that the host both writes and reads produced no difference at all on either target, with the name genuinely rewritten in the emitted file. Names that never leave your build are not the problem. Names that appear on both sides of a boundary are.

A valid plugin fails its own load check

With member renaming on and a pattern matching the three names a host reads first, the load check inverted. The list of problems went from none to naming all three, the plugin name printed as undefined, the declared API version printed as undefined, and the compatibility test went from true to false. The plugin sets all three, plainly, in the first four lines of its own file.

Nothing was thrown. The host did exactly what a careful host is supposed to do: it found the required properties missing and declined to load the module. The failure is well handled, well logged and completely wrong, and every word of the log entry points at the vendor.

The same pattern removed the plugin's setup call. Because the host tests whether setup is callable before calling it, the emitted build reported that setup was not callable and skipped it, so no commands and no listeners were registered. An application that silently loses half its features while reporting that a third-party module is malformed is a support case that can run for days before anybody suspects the build step.

A separate arm covers the hook table, which is the quiet version of the same thing. With a pattern matching the hook container and the hook name, the host read the hook table through a guard that substitutes an empty object when it is missing. The hook count went from one to none, the capability test went from true to false, and the hook simply never ran. No error, no missing feature message, just a transform that stopped being applied to every file that passes through it.

The error is thrown inside the vendor's code

The other direction is louder and more misleading. The context object is written by the host: it carries the functions a plugin calls to register a command or subscribe to an event. With a pattern matching those two names, the plugin's first line threw TypeError: ctx.registerCommand is not a function.

Read where that error comes from. The property was renamed in your build, but the call site is in the plugin author's file, which was not part of your build and reads exactly as it always did. A stack trace points at their module, the message quotes a method name they are calling correctly, and the property list on the object they received contains generated identifiers. Every instinct says the plugin is broken.

A milder arm shows the same direction without the throw. With a pattern matching the options object the host passes into the context, the plugin still ran, still registered its commands, and registered one of them under a name built from a value it read off your options object. That value came back undefined, so the command was registered under a name derived from nothing. The plugin behaved perfectly and produced a wrong result, which is the hardest failure of all to attribute.

A capability probe arm behaves the same way. The host reads a declared engine range off the plugin and prints it; renaming that name turned a real declared range into the string used when nothing is declared. Version negotiation between a host and a plugin runs entirely on property names, so it is the first thing to stop working and the last thing anybody checks.

Two builds, two name maps, and a plugin name that is a function

The arms above deliberately leave the plugin unprotected. The other realistic arrangement is that the vendor protects their own bundle, with their own settings, in their own pipeline. That needed a purpose-built experiment rather than an arm: protect the host in one run, protect the plugin in a second run, use the same member pattern for both, and see what the host reads.

Generated member names are assigned per build, in the order the names are first seen in that build. Two builds therefore start two independent counters at the same number. In the experiment the host numbered its own context members first and reached the plugin contract names later; the plugin numbered its own vendor field first and reached them earlier. The names did not merely differ. They collided.

The host printed the plugin's setup function, source and all, in the field where the plugin name belongs. The declared API version read as undefined, the compatibility check failed, and no commands were registered. A log line that is supposed to read like a product name instead contains a function body, which is at least visible; the accompanying verdict, that the plugin is incompatible, is the part that gets believed.

This is the same mechanism recorded for cross-file builds, arriving through a different door. There it was two files of one release. Here it is two organisations, two release cycles and two configuration files, with no shared build step where anybody could notice.

The negative control is where the danger lives

The obvious response is to make both sides declare the contract in the same order, so the counters line up. The experiment includes that as its negative control: the same plugin with nothing declared above the shared names, protected separately, against the same host.

It did not work. The host still read undefined for the plugin name and still refused to load the module. The reason is worth stating carefully, because it is the part that breaks the intuition: each build numbers every name it sees, not only the shared ones. The host sees its own context method, its own options object and its own flag before it ever reads a plugin property, so by the time it reaches the shared names its counter is already three ahead of the plugin's.

That makes the alignment idea worse than useless. There is no ordering discipline a plugin author could follow, because the offset depends on how many private members the host happens to touch first, which changes whenever the host changes. And when the offset lands differently, as in the misaligned column, the failure changes from a missing property to a wrong one.

The positive control completes the picture. Protecting the host and the plugin together, as two script chunks of a single project, produced correct output on both targets: the plugin name, the API version, the compatibility verdict and the registered command all matched the unprotected baseline. One run shares one name map, which is exactly why one run works and two runs do not.

What this means in practice

If you ship an application with a plugin API, or an SDK that other people's code calls, treat the entire public surface as names you do not own. Every property a plugin sets, every method you expose on a context object, every field in a manifest a plugin author writes: none of them can be renamed, because the other half of each pair is compiled into somebody else's file.

The cheapest enforcement is a naming convention plus a member pattern anchored to it. Give internal members a prefix and match only that prefix. The control arm in this measurement is the whole argument for it: a name the host writes and reads was renamed on both targets with no behavioural difference at all. Renaming is safe precisely where nothing outside the build can see it.

Do not rely on aligning declaration order between builds, and do not assume a per-plugin build step is a workable substitute for one project. The negative control failed while looking exactly like the arrangement that ought to work, and the misaligned column shows what happens when the offset moves: values arrive attached to the wrong names rather than going missing.

Verify against the protected artifact, not the source. Load one real plugin in a build with protection switched on and print three things: the plugin name your host read, the verdict of the compatibility check, and the list of commands actually registered. Compare each against a literal you typed. Comparing one read of the plugin object with another read of the same object cannot detect this, because both reads are renamed together and agree with each other while both are wrong.

Frequently asked questions

Does obfuscation break plugin and extension APIs?

Not in the default configuration. A host that loads a third-party plugin module, validates it, hands it a context object, collects its registrations and calls one of its hooks produced identical output on all five protection profiles measured. A plugin API only becomes a surface when member renaming is switched on and the pattern matches names that appear on both sides of the boundary.

Why does my host reject a plugin that used to load?

Because the host is reading generated names off an object that carries the original ones. In the measured run a pattern matching the plugin name, its API version and its setup function made all three read as undefined, so the load check reported all three missing and declined the module. The plugin sets every one of them in its own first four lines.

Why does the error name the plugin's code rather than mine?

Because the property that was renamed is one the plugin calls. Renaming the methods on the context object the host passes in produced a TypeError thrown inside the vendor's setup function, quoting a method name they call correctly. Their file was never part of your build, so the stack trace points at the one piece of code that did not change.

Can a protected host and a separately protected plugin agree on names?

No, and an experiment was built to test exactly that. Generated member names are numbered per build in the order each build first sees them, so two builds start two counters. Protecting a host and a plugin in separate runs made the host print the plugin's setup function where the plugin name belongs, then reject the plugin as incompatible.

Does declaring the contract in the same order in both builds fix it?

It does not. That was measured as a negative control and still failed, because each build also numbers its own private members. The host reached the shared names three positions later than the plugin did, so the plugin name read as undefined even though both files declared the contract identically. There is no ordering discipline that survives a change to either side.

My plugin loads but its hooks never run. Is that the same problem?

It is the quiet version of it. A pattern matching the hook container and the hook name made the hook table read as empty through the guard that substitutes an empty object, so the capability test returned false and the hook was never called. Nothing was thrown and nothing was logged; a transform simply stopped being applied.

How should member renaming be configured in a product with a plugin API?

Anchor the pattern to a prefix used only by internal members, so nothing on the public surface can match by accident. Keep every file of one release in one project and one run, since one run shares one name map and produced correct output in the positive control. Then verify by loading a real plugin against the protected build and comparing what the host read with a literal you typed.

Related reading