Compatibility

Does Obfuscation Break Child Process and Worker Options?

Server-side and desktop JavaScript spends a lot of its time handing option dictionaries to the runtime. You call spawnSync with { encoding, env, cwd, maxBuffer }, or construct a worker with { eval, workerData }, and every one of those keys is read by code compiled into node rather than by anything in your bundle. That makes them the same class of contract as a platform options object in the browser, with one difference that shows up in the results: several of these keys are not configuration at all. They are safety limits.

What was measured

One sample that does three things a real tool does. It runs a child process with an explicit encoding, a curated environment, a working directory and a timeout, and prints what came back. It runs a second child that deliberately produces far more output than the buffer limit allows, so that the limit's behaviour is visible. And it starts a worker thread from a code string with structured data attached, then prints the message the worker sends back and the code it exits with.

Every one of those option keys is read inside node. The child process is a separate operating-system process that never sees your source; the worker deserialises its data on another thread. Nothing in the bundle can keep a renamed key consistent with either of them, which is what makes this area worth a measurement rather than an assumption.

The base column is clean across the board. Default target, modern target, gate profile, modern gate profile and string transforms all reproduced the original output exactly, including the child's stdout, the exit status, the buffer error code and the worker's message. Spawning processes and starting threads is not affected by protection.

The quiet ones: a string that becomes a Buffer, and a directory that is not yours

A pattern matching encoding changed the type of the result without changing anything else: type=string became type=object. Node was never told to decode the child's output, so it handed back a Buffer, which is its documented default. Nothing threw, because a Buffer stringifies perfectly well when you interpolate it into a message. Every string method you then call on it -- trim, split, includes, a regular expression test -- is where it stops being fine, and that can be a long way from the spawn call.

A pattern matching cwd was quieter still. The child ran, produced correct output and exited zero; the only visible difference was the length of the directory it reported, which went from the temporary directory the sample asked for to a much longer path it did not. The option was not honoured, so node used its default, which is the parent's working directory. Any relative path that child resolves is now resolved somewhere else -- and a tool that writes its output relative to the working directory will write it in the wrong place, successfully.

A pattern matching maxBuffer is the one to think about hardest, because it disables a limit rather than changing a value. The original run reported ENOBUFS for a child that produced five kilobytes against a one-kilobyte cap. The protected run reported no error at all. The cap was not raised, it was never applied, so the guard against a runaway child filling memory is simply not present in the build. This is the same shape as a validating decoder that quietly becomes permissive: nobody sets a limit by accident, and the code path that removes it is the one nobody tests.

What the child inherits when the environment key is renamed

A pattern matching env produced a child that could not see the variable the sample set for it: the child printed CHILD:undefined where the original printed CHILD:alpha. The dictionary is still in the emitted file, still holds the right value, and is attached to a key node does not read.

The half that does not appear in the printed output is the more interesting half. When the environment option is absent, node does not give the child an empty environment -- it gives it a copy of the parent's. So a call written specifically to hand a child a small, curated set of variables instead hands it everything the parent process holds, which in a build tool or a desktop application is very often every credential, token and signing key in the environment. The child in that arm both lost the variable it was supposed to have and gained every variable it was supposed not to have, and neither of those is reported anywhere.

Worth noting for exactly the same reason as the first article in this pair: the failure is not that the option was mangled. The option was preserved perfectly and attached to a name the reader does not know.

The result object is the same contract, read in the other direction

Options go out; results come back, and the field names on the result are node's too. A pattern matching stdout, status and signal left the child running perfectly and made the parent blind to it: type=undefined, stdout=undefined, status=undefined signal=undefined. The other child's captured output dropped to nine characters, which is the length of the string undefined.

The consequence is the interesting part. Almost every wrapper around a child process decides success by comparing the exit status against zero, and after renaming that comparison is made against undefined on every run. Whichever way the comparison is written, it now returns the same answer for a child that succeeded and a child that crashed, because the value it reads no longer varies. The subprocess still ran, still did whatever it does, and still set a real exit code; the build simply cannot see it any more.

The loud ones

Two arms fail immediately and deserve credit for it. A pattern matching the worker's data keys stopped the worker with Cannot read properties of undefined (reading 'jobId') and an exit code of one, because the worker received no data object at all and said so on the first line that touched it. A pattern matching eval threw TypeError [ERR_WORKER_PATH] before the worker started at all: without that flag node treats the first argument as a filename rather than as source, and a few hundred bytes of JavaScript is not a valid path.

Both of those surface on the first run of the protected artifact. They are the arms you will never ship, which is precisely why they are the least important ones in the article.

Where this matters, and what to do about it

This is a node-side and desktop-side concern rather than a browser one. It applies to a protected CLI tool, a build plugin, an Electron main process spawning helpers, and any packaged application that shells out. In the browser there is no spawnSync, and the equivalent surface is the platform options dictionaries covered elsewhere on this site.

The mitigation is the one that keeps coming up, and it is unusually easy to apply here because the vocabulary is small and stable. Node's option and result keys -- encoding, env, cwd, shell, timeout, maxBuffer, stdio, eval, workerData, transferList, stdout, stderr, status, signal -- are known in advance and can go in a reserved list once. A member pattern anchored to your own domain names will not match them anyway.

For verification, prefer the arms that produce a value over the arms that produce a crash. Print the type of a captured stdout, not just its contents. Print the exit status of a child you deliberately make fail, and confirm you can tell it apart from one that succeeded. Print the working directory the child actually used. And check the limits explicitly, because a limit that has silently stopped applying looks exactly like a limit that was never reached.

Frequently asked questions

Does obfuscation break child processes in Node?

Not in the default configuration. Spawning a child with an explicit encoding, environment, working directory and timeout, exceeding a buffer limit deliberately, and starting a worker thread with attached data all produced identical output across five protection profiles. These option dictionaries only become a surface when member renaming is switched on.

Why does my child process output come back as a Buffer instead of a string?

Because the encoding key was renamed, so node never saw the instruction to decode and returned its documented default. Nothing throws at the spawn call, since a Buffer interpolates into a message perfectly well. The failure surfaces later at the first string method called on the result.

What happens if the env option key is renamed?

The child loses the variables you meant to give it and gains the ones you did not. In the measured arm the child could not see the variable the sample set, and because node copies the parent environment when the option is absent, a call written to hand a child a small curated set instead hands it everything the parent holds. Neither half is reported anywhere.

Can renaming disable a safety limit rather than change a value?

Yes, and maxBuffer is the measured example. A child producing five kilobytes against a one-kilobyte cap reported ENOBUFS before protection and no error after it. The cap was not raised, it was never applied, so the guard against a runaway child is absent from that build.

Why can my code no longer tell whether a child process succeeded?

Because the result field names belong to node too. A pattern matching stdout, status and signal left the child running correctly and made every one of those reads undefined, so the usual comparison of exit status against zero returns the same answer for a child that succeeded and one that crashed.

Which worker option failures are immediate?

Both of the measured worker arms fail on the first run. Renaming the attached data keys stops the worker with a TypeError on the first property it touches and an exit code of one, and renaming the eval flag throws ERR_WORKER_PATH before the worker starts, because node then treats the source string as a filename.

How do I protect a Node tool without hitting this?

Node's option and result key names are a small, stable and known-in-advance vocabulary, so add them to your reserved list once, or anchor your member pattern to names distinctive to your own application so they are never candidates. To verify, print the type of a captured stdout, the exit status of a child you deliberately make fail, and the working directory the child actually used.

Related reading