AfterPack changes how your code reads and keeps what it does. An obfuscated build returns the same values, throws the same kinds of errors, writes the same output and makes the same changes to the page as the build you gave it. This page states that contract for engine 0.2.3 and later: what stays the same, how each release is checked, and the few places where the output differs on purpose.
What stays the same
At every preset and with every seed, the obfuscated build keeps:
- Results and errors. Functions return the same values. Code that threw still throws an error of the same type.
- Effects and their order. Console output, DOM changes, network requests and storage writes happen in the same order, with the same values.
- Async order.
asyncfunctions, promises and timers settle in the same order. - The global surface. A classic script defines the same globals as before, so other scripts on the page can still call its functions and read its variables. An ES module adds nothing to
windowand overwrites no global. A file withimportorexportis read as a module; for one without, a framework plugin, the CLI orsourceTypetells the engine. - The language rules your code relies on. Strict-mode errors,
thisand call receivers, getters and setters, class fields,BigIntarithmetic, and the scopes ofevalandwithbehave as written. - Names your code reads. In a file that reads
this.constructor.nameornew.target.name, as a custom error class does, classes and constructors keep the names you wrote, with the exceptions under deliberate differences.
Two builds with different seeds differ in shape, never in behavior.
What changes by design
- The text of the code. What
fn.toString()returns, line and column numbers, and the names in a stack trace all change. Keep a source map private to read production stack traces. - Size and speed. Output is larger, and from
mediumup it does more work per operation. Performance has the measured numbers. - Code that inspects itself. When the engine finds code that reads its own structure at runtime, such as
fn.toString(), it stops the build and names the pattern. Acknowledge the pattern withreflection.allow, or ship the file untouched withpaths.exclude.
How each release is checked
From engine 0.2.3, every engine release passes a semantic gate before it ships. The gate runs each program twice, as written and obfuscated, and compares what each run does: return values, thrown errors, console output, and the order of asynchronous work. A difference fails the release unless it is one of the deliberate differences below.
The gate runs:
- Small targeted programs, each aimed at one language rule a code transformer can get wrong: classes and private fields, closures and scopes, generators,
asyncorder, template literals,BigInt,evalandwith, strict mode, labels andswitch. Every preset, many seeds, and both the native engine and the WebAssembly engine. - Real libraries at pinned versions, each driven by a small script: dayjs, Moment.js and Luxon for dates, viem, ethers and @noble/curves for
BigInt-heavy cryptography, Comlink, lodash's_.templateand get-intrinsic. - Pages in Chromium that load several classic scripts in order, ES modules with and without imports, inline event handlers and custom elements, and a single-chunk Vue app. Each page must behave the same and leave
windowexactly as the original does, with no new globals and none overwritten.
Deliberate differences
These and the class names after the table are the only differences the gate allows. Each row involves code that replaces built-ins, wraps instances in a Proxy, calls a class without new or reads engine error messages, so most applications never meet them. They apply inside classes the engine rewrites, from light up; minify leaves classes as written.
| Your code | Native behavior | Obfuscated output |
|---|---|---|
Defines public class fields on an instance that is a Proxy (returned from a base constructor) or has one in its prototype chain | Only the proxy's defineProperty trap runs | The has trap also runs for each field, and for a field the object lacks, the set trap and whatever it forwards to |
Calls a class constructor without new | Throws a TypeError before any parameter default or destructuring runs | Runs the parameter defaults and destructuring, then throws the same TypeError |
Calls a private method or super.method() after replacing Function.prototype.call, or on a method with its own call property | Calls the method directly | Goes through the replaced call |
Reads the message of a TypeError from a private-field check, a second private-field initialization or a class called without new | The JavaScript engine's own message | AfterPack's own message, with the same error type |
Replaces TypeError, Object, Object.defineProperty, Reflect.construct, Reflect.ownKeys, WeakMap or WeakSet before the file loads | Classes still use the original built-ins | Rewritten classes use the replacement |
Class and constructor names differ in a few cases, at every preset. A file that reads them only in other ways, such as err.constructor.name or const { name } = new.target, gets the renamed names. In a file that does read this.constructor.name or new.target.name, the renamed name still shows for a class constructed as new (class X {})(), an anonymous class named by a default parameter, a function constructed from a switch case that comes before the case declaring it, and a function constructed only through another reference, such as new ns.F(), an alias or Reflect.construct.
What stays as you wrote it
These constructs are left as written wherever rewriting them faithfully is not cheap. The code around them is still renamed and encoded, except around with and direct eval.
asyncfunctions andawait.delete, andtypeofapplied to a bare name.- A function whose
catch (e)block declaresvar e. - A
letorconstdeclared directly in aswitchcase. - An optional call or tag whose callee is in parentheses or is a
supermember:(o?.m)?.(),(o?.m)`x`,super.m?.(). - A class that assigns to its own name, calls a
supermember as an optional call, tag or parenthesized callee, or has a derived constructor without exactly one top-levelsuper()call. - Loops, at
minifyandlight. - The scopes around a
withstatement or a directeval. Names in every scope that encloses it, up to the top of the file, keep their original spelling, and globals inside awithbody are left as written. The build reports it withDIAG_DYNAMIC_SCOPE_NATIVE. Where the evaluated code needs no local variables, callevalindirectly,(0, eval)(code), or move the code that needsevalorwithinto a file of its own.
Before engine 0.2.3
Engine 0.2.2 and earlier did not meet this contract in some cases the gate now covers: top-level names of classic scripts shared with other scripts, ES modules without imports, objects such as dates inside template literals, functions used as constructors, class fields over inherited accessors, functions that refer to themselves, BigInt arithmetic at medium and above, eval and with, a catch block that redeclares its variable with var, async order, optional calls in parentheses, constant ** expressions, and class names read at runtime. Rebuild with engine 0.2.3 or later; the changelog lists each fix.
Report a difference
A difference not listed on this page is a bug. Open an issue with a small reproduction at github.com/afterpack-dev/afterpack/issues. Until it is fixed, paths.exclude ships the affected file untouched.
Next
- Diagnostics: every code a build can report, and what to do about it.
- How AfterPack works: what the transformations do to your code.
- Presets: what each level applies.
- Configuration:
reflection.allow,paths.excludeand the other keys this page links.