Build & Delivery

Obfuscating JavaScript in a monorepo

The instinct in a monorepo is to treat protection as a per-package concern: add a protect script to each package.json, wire it into the task graph, done. It produces a pipeline that is slower, larger, harder to debug and no more protected than the correct version — because a monorepo has many packages and very few releases, and protection belongs to the release.

Find the deployment boundary

Walk your workspace and sort every package into one of three buckets. The bucket decides the treatment, and almost nothing else does.

  • Deployables. The web app, the Electron main process, the Node service, the browser extension. Someone outside your organisation receives these bytes. These are the protection targets.
  • Internal packages. @acme/ui, @acme/utils, @acme/api-client. They exist to be consumed by deployables and are never shipped on their own. Do not protect these. Their code reaches the outside world through a deployable, and it is protected there.
  • Published packages. Anything you npm publish, whether public or to a private registry. A separate decision with its own trade-offs — should you obfuscate an npm package you publish? covers it, and the short answer is usually no for a library consumers will bundle.

Most repositories find that thirty packages collapse to two or three protection targets. That is the correct outcome, not an oversight.

Why protecting internal packages is actively harmful

It is not merely redundant. Protect @acme/utils, and the application build now consumes an already-protected artifact as a source input. Three things follow.

Your bundler loses its grip. Tree shaking depends on statically analysable imports and side-effect-free module shapes. Protected code is a much harder analysis target, so the bundler conservatively keeps code it would otherwise have dropped. You ship more bytes than you would have unprotected, and the extra bytes are the ones the transform added.

The app's protection pass runs over protected code. Whatever the app-level configuration does — renaming, string extraction, control-flow work — it now applies on top of the emitted decoder and lookup tables from the first pass. It is generally correct, and it compounds size and slows the result for no security gain, since the second pass adds nothing an attacker cannot already handle.

Failures become unattributable. A silent breakage in a doubly-protected artifact gives you two configurations, two builds, and two candidate causes for a symptom that surfaces nowhere near either. The verification ladder is hard enough to walk with one protection pass.

Protect once, at the boundary, over bundled output. The rule is the same one that says not to obfuscate .svelte or .tsx source: protection consumes build output, and inside a monorepo an internal package is build input.

There is no shared identifier map

The question that always arrives next: if two deployables both bundle @acme/api-client, can they agree on the renamed identifiers? They cannot, and it is worth being blunt because competing tools advertise an identifier cache that suggests otherwise.

Each protection run makes its own naming decisions. Fields such as identifierNamesCache and identifiersDictionary are accepted by the migration path so that a config coming from another tool converts cleanly, but they are captured for review rather than honored as a shared cache — the CLI's --identifier-cache-review report exists precisely to list them and tell you what to do instead.

In practice this matters far less than it sounds, because a name only needs to agree when it crosses a boundary at runtime — and inside one bundled deployable there is no boundary to cross. The cases that do need attention are the familiar ones, and they are handled with reserved names rather than a cache:

  • Two deployables exchanging structured messages — a postMessage payload, an Electron IPC channel, a shared localStorage schema. The keys are a contract between two independently protected programs.
  • A shared package that reads keys off an API response. The server owns those names, so both sides must reserve them.
  • A micro-frontend loading another team's protected bundle at runtime and calling into it by name.

Keep one reserved-names list at the repo root, shared by every deployable, and treat additions to it as a reviewable change. A name that crosses any boundary belongs there even if only one app currently uses it.

One config, many packages

Configuration files can be JavaScript rather than JSON — .cjs, .mjs and .js are all loaded — so a shared base with per-app overrides needs no tooling of its own:

// tools/jso.base.cjs
module.exports = {
  preset: "balanced",
  exclude: ["**/*.map", "**/vendor/**"],
  reservedNames: require("./reserved-names.json"),
  seed: process.env.RELEASE_TAG || require("../package.json").version
};

// apps/checkout/jso.config.cjs
const base = require("../../tools/jso.base.cjs");
module.exports = (ctx) => ({
  ...base,
  input: "dist",
  output: "dist-protected",
  // Only the checkout app pays for VM protection.
  preset: ctx.mode === "production" ? "maximum" : base.preset,
  manifest: "dist-protected/jso-manifest.json"
});

The exported function receives the release mode passed with --mode, falling back to NODE_ENV. That is what keeps staging builds cheap while production builds get the expensive settings, without a second config file to drift.

For the CI side — iterating every app that has a config and labelling each run so the audit log groups by app — Cookbook recipe 8 has the loop; there is no reason to write it twice.

The task-runner cache problem

This is the one that surprises people, and it is not a monorepo bug — it is polymorphism meeting content-addressed caching. Turborepo, Nx and every similar runner decide whether to replay a task by hashing its inputs, and they hash task outputs to decide whether downstream work is still valid.

Protected output is deliberately different on every run. So a protect task with completely unchanged inputs emits changed bytes, every dependent task sees a changed input, and your carefully-tuned graph degrades to a full rebuild on every invocation. The same mechanism ruins browser caching, which the long-term caching article covers in detail. Two ways out:

  • Seed per release. With a seed derived from the version or release tag, the same inputs produce byte-identical output, and the cache behaves normally again for every rebuild of that release. Consecutive releases still differ from each other, so you keep the property polymorphism was there for. This is the right default.
  • Make protection terminal and uncacheable. If you would rather keep unseeded per-build variety, place protection at the end of the graph with nothing downstream of it and mark it non-cacheable. Then its nondeterminism cannot invalidate anything, because nothing depends on it.

What does not work is leaving it unseeded in the middle of a cached graph and wondering why CI takes twice as long as it used to.

Verify per deployable

A monorepo makes it tempting to run one smoke test over "the build". Protection failures are per-artifact, so verification has to be too. For each deployable: run that app's own end-to-end suite against its protected output, keep the --manifest and --report next to the artifact, and give each run a --label containing the commit and the app name so a production stack trace can be traced back to the exact build that produced it. Then symbolication works, because you can find the right identifier map among a dozen releases of six apps.

The short version

Sort the workspace into deployables, internal packages and published packages. Protect the first group once, over bundled output, at the end of each app's build. Leave the second group alone entirely. Share one reserved-names list and one base config across the repo, seed from the release so your caches keep working, and verify each deployable separately with its own manifest and label. The number of protection runs should equal the number of things you ship — not the number of folders under packages/.

Frequently asked questions

Should I protect every package in my monorepo?

No. Protect each deployable artifact once, after bundling. Internal packages are inputs to a build, not releases - protecting them separately means the application bundler consumes already-protected code and the app's own protection pass runs over it a second time.

Do identifier names stay consistent across separately protected packages?

No. There is no shared identifier map between protection runs, and the identifier-cache fields from competing tools are accepted only for migration review, not honored as a cache. Anything crossing a package boundary must be handled with reserved names, or protected in a single pass over the bundled output instead.

Why does my Turborepo or Nx cache always miss after adding obfuscation?

Protected output is polymorphic, so the protect task emits different bytes on every run even with unchanged inputs. Any task consuming that output sees a changed input and re-runs. Fix it by seeding the protect task per release so its output is deterministic, or by marking it non-cacheable and placing it last.

How do I share one protection config across many packages?

Use a JavaScript config file. A jso.config.cjs can require a shared base from the repo root and spread per-package overrides on top, and it may export a function that receives the release mode passed with --mode.

Related reading