Skip to content

Commit a078fbd

Browse files
committed
Add the Packer blog post
1 parent da4b8a9 commit a078fbd

3 files changed

Lines changed: 373 additions & 0 deletions

File tree

646 KB
Loading
630 KB
Loading

src/content/blog/packer/index.md

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
---
2+
title: 'Performance Archaeology: Packer.js, a JS Minifier from 2004'
3+
alternativeTitles:
4+
social: 'Performance Archaeology: Packer.js'
5+
seo: 'Performance Archaeology: Packer.js, a JS Minifier from 2004'
6+
author:
7+
id: iamakulov
8+
name: Ivan Akulov
9+
link: https://iamakulov.com
10+
twitterId: iamakulov
11+
facebookId: '100002052594007'
12+
description: 'Reverse-engineering a surprisingly effective JS minifier from 2004'
13+
rssDescription: |
14+
Back in 2018, while doing a performance audit for a client, I stumbled upon an unusually-looking piece of code:
15+
16+
<code>eval(function(p,a,c,k,e,r){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String))...</code>
17+
18+
This code was weird. Why an <code>eval()</code>? What’s with <code>p,a,c,k,e,r</code> which clearly combines into some name? I couldn’t help but dig into that, and the trip took me all the way back to 2004.
19+
socialImage:
20+
facebook: './cover-facebook.jpg'
21+
twitter: './cover-twitter.jpg'
22+
date:
23+
published: 2025-03-23T20:00:00
24+
modified: 2025-03-23T20:00:00
25+
---
26+
27+
Back in 2018, when doing a performance audit for a client, I stumbled upon an unusually-looking piece of code:
28+
29+
```javascript{wordWrap: true}
30+
/*!
31+
* Head JS
32+
* Copyright Tero Piirainen
33+
* License MIT
34+
* Version 1.0.3
35+
*
36+
* https://github.com/headjs/headjs
37+
*/
38+
eval(function(p,a,c,k,e,r){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--)r[e(c)]=k[c]||e(c);k=[function(e){return r[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k[c])p=p.replace(newRegExp('\\b'+e(c)+'\\b','g'),k[c]);return p}('(7(n,t){"1A 1B";7 r(n){a[a.A]=n}7 k(n){m t=31 32(" ?\\\\b"+n+"\\\\b");c.19=c.19.29(t,"")}7 p(n,t){C(m i=0,r=n.A;i<r;i++)t.J(n,n[i],i)}7 Y(){m t,e,f,o;c.19=c.19.29(/ (w-|Z-|V-|E-|K-|F-|1C|1a-1C|1D|1a-1D)\\d+/g,"");t=n.2a||c.33;e=n.2b||n.L.1E;u.L.2a=t;u.L.2b=e;r("w-"+t);p(i.2c,7(n){t>n?(i.Q.V&&r("V-"+n),i.Q.E&&r("E-"+n)):t<n?(i.Q.K&&r("K-"+n),i.Q.F&&r("F-"+n)):t===n&&(i.Q.F&&r("F-"+n),i.Q.Z&&r("e-q"+n),i.Q.E&&r("E-"+n))});f=n.2d||c.34;o=n.2e||n.L.1F;u.L.2d=f;u.L.2e=o;u.G("1C",f>t);u.G("1D",f<t)}7 12(){n.1b(b);b=n.14(Y,1G)}m y=n.1H,1c=n.35,1n=n.36,c=y.1I,a=[],i={2c:[37,38,39,3a,3b,3c,3d,3e,3f,3g,3h],Q:{V:!0,E:!1,K:!0,F:!1,Z:!1},1d:[{1o:{2f:6,2g:11}}],R:{V:!0,E:!1,K:!0,F:!1,Z:!0},2h:!0,1J:"-1J",1e:"-1e",M:"M"},v,u,s,w,o,h,l,d,f,g,15,e,b;x(n.S)C(v N n.S)n.S[v]!==t&&(i[v]=n.S[v]);u=n[i.M]=7()...'
39+
```
40+
41+
This code was weird. Why an `eval()`? What’s that obfuscated code string? Luckily, the beginning of the file gave a hint where it came from:
42+
43+
```javascript
44+
eval(function(p,a,c,k,e,r){...
45+
```
46+
47+
:::sidenote[[GitHub mirror of Packer](https://github.com/evanw/packer/tree/master?tab=readme-ov-file)]
48+
After a bit of research, I found the answer: this code was produced by a tool called [Packer](https://web.archive.org/web/20120929074838/http://dean.edwards.name/packer/)! (Duh.)
49+
:::
50+
51+
Packer is a JavaScript minifier made by Dean Edwards, and it appears to be one of the very first tools of its kind. It was released [in 2004](https://web.archive.org/web/20040404032900/http://dean.edwards.name/packer/); for comparison, [Closure Compiler](https://googlecode.blogspot.com/2009/11/introducing-closure-tools.html) – a JS minifier made by Google and written in Java – was published in 2009, and [UglifyJS](https://github.com/mishoo/UglifyJS-old/tree/v1.0) – a minifier that was a de-facto standard in 2010s until it got replaced by its fork Terser – was released around 2011. The only minifier that precedes Packer (and that I’m aware of) is [JSMin](https://www.crockford.com/jsmin.html), from 2001.
52+
53+
A typical minifier compresses code by [removing whitespace, shortening variable names](https://esbuild.github.io/try/#dAAwLjI0LjIALS1taW5pZnkAZnVuY3Rpb24gc3VtKC4uLmFyZ3MpIHsKICBsZXQgcmVzdWx0ID0gMDsKICBmb3IgKGNvbnN0IGkgb2YgYXJncykgcmVzdWx0ICs9IGk7CiAgcmV0dXJuIHJlc3VsdDsKfQoKc3VtKDEsIDIsIDMp), and other similar tricks. Packer, however, does something completely different. How does it work?
54+
55+
# How Does Packer Work?
56+
57+
:::sidenote[[Live mirror of Packer 3.0](https://web.archive.org/web/20120929074838/http://dean.edwards.name/packer/)]
58+
The latest available version of Packer is 3.0, published in Aug 2007. If you paste some example code into its UI:
59+
:::
60+
61+
```javascript
62+
function sum(arguments) {
63+
var result = 0;
64+
for (var i = 0; i < arguments.length; ++i) result += arguments[i];
65+
return result;
66+
}
67+
68+
console.log(sum(10, 20, 30))
69+
```
70+
71+
:::sidenote[With “Base62 encode” unchecked, Packer will simply remove the whitespace and skip the whole `eval(function(p,a,c,k,e,r)` thing. Boring!]
72+
then check “Base62 encode” and click “Pack”, you’ll get code that starts exactly like the one from the beginning of this article:
73+
74+
<!-- prettier-ignore -->
75+
```javascript{wordWrap: true}
76+
eval(function(p,a,c,k,e,r){e=function(c){return c.toString(a)};if(!''.replace(/^/,String)){while(c--)r[e(c)]=k[c]||e(c);k=[function(e){return r[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k[c])p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c]);return p}('6 4(2){5 3=0;7(5 1=0;1<2.8;++1)3+=2[1];9 3}a.b(4(c,d,e))',15,15,'|i|arguments|result|sum|var|function|for|length|return|console|log|10|20|30'.split('|'),0,{}))
77+
```
78+
79+
:::
80+
81+
What does this code do? Let’s format it:
82+
83+
```javascript
84+
eval(
85+
(function (p, a, c, k, e, r) {
86+
e = function (c) {
87+
return c.toString(a);
88+
};
89+
if (!''.replace(/^/, String)) {
90+
while (c--) r[e(c)] = k[c] || e(c);
91+
k = [
92+
function (e) {
93+
return r[e];
94+
},
95+
];
96+
e = function () {
97+
return '\\w+';
98+
};
99+
c = 1;
100+
}
101+
while (c--)
102+
if (k[c]) p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]);
103+
return p;
104+
})(
105+
'6 4(2){5 3=0;7(5 1=0;1<2.8;++1)3+=2[1];9 3}a.b(4(c,d,e))',
106+
15,
107+
15,
108+
'|i|arguments|result|sum|var|function|for|length|return|console|log|10|20|30'.split(
109+
'|',
110+
),
111+
0,
112+
{},
113+
),
114+
);
115+
```
116+
117+
and try to understand what each of its parts does:
118+
119+
- **High-level structure**
120+
121+
```javascript
122+
eval(
123+
(function (p, a, c, k, e, r) {...})
124+
(...)
125+
)
126+
```
127+
128+
At the high level, the code declares a function (`function (p, a, c, k, e, r)`), immediately calls it with some arguments, and then passes the return value into `eval()`.
129+
130+
`p`, `a`, `c`, `k`, `e`, and `r` are the function’s arguments, and they receive the following values:
131+
132+
- **Argument 1 (`p`)**
133+
134+
```javascript{wordWrap: true}
135+
/* p = */ '6 4(2){5 3=0;7(5 1=0;1<2.8;++1)3+=2[1];9 3}a.b(4(c,d,e))';
136+
```
137+
138+
This is the minified version of the original code! Note how
139+
140+
- all special characters in the original code remain unchanged in the minified code: `for (var i = 0; i < arguments.length; ++i)` becomes `7(5 1=0;1<2.8;++1)`
141+
- but all keywords are replaced with numbers or letters: `function sum(arguments)` becomes `6 4(2)`, and `console.log()` becomes `a.b(...)`
142+
143+
What do these numbers (`6`, `4`, `2`, etc) and letters (`a`, `b`, etc) correspond to? These seem to come from...
144+
145+
- **Argument 4 (`k`)**
146+
147+
```javascript
148+
/* k = */ "|i|arguments|result|sum|var|function|for|length|return|console|log|10|20|30"
149+
.split("|"),
150+
151+
// Results in
152+
// ['', 'i', 'arguments', 'result', 'sum', 'var',
153+
// 'function', 'for', 'length', 'return', 'console',
154+
// 'log', '10', '20', '30']
155+
```
156+
157+
These are keywords extracted from the original code.
158+
159+
When Packer minifies `function sum(arguments)`, it extracts each keyword, puts it into this array, and replaces each keyword with its index in the array (in [base-62 encoding](https://en.wikipedia.org/wiki/Base62)). So `function sum(arguments)` becomes `6 4(2)`, and `console.log(...)` becomes `a.b(...)`.
160+
161+
Why is item 0 in this array empty? That’s because number `0` is already used in the original code (`var result = 0`). Keeping that number as-is results in smaller code than replacing it with an index.
162+
163+
- **Arguments 2 (`a`) and 3 (`c`)**
164+
165+
```javascript
166+
/* a = */ 15,
167+
/* c = */ 15,
168+
```
169+
170+
These arguments specify extra details about the keywords array above.
171+
172+
Argument 2 is the base of the encoding used to index keywords. A few paragraphs above, I said keywords are encoded using base-62 encoding, but that’s not actually true. Technically, the base of the encoding is variable, and can be anything from 1 to 62. In practice, it always equals the number of keywords – unless there are more than 62 keywords, in which case it’s just 62. This is equivalent to fixed base-62 encoding, so I’m not sure why 62 isn’t just hard-coded.
173+
174+
Argument 3 is the number of all keywords in the array.
175+
176+
- **Arguments 5 (`e`) and 6 (`r`)**
177+
178+
```javascript
179+
/* e = */ 0,
180+
/* r = */ {},
181+
```
182+
183+
Arguments 5 and 6 are always `0` and `{}`. They’re used to store some intermediate data.
184+
185+
- **Runtime**
186+
187+
```javascript
188+
function (p, a, c, k, e, r) {
189+
e = function (c) {
190+
return c.toString(a);
191+
};
192+
if (!"".replace(/^/, String)) {
193+
while (c--) r[e(c)] = k[c] || e(c);
194+
k = [
195+
function (e) {
196+
return r[e];
197+
},
198+
];
199+
e = function () {
200+
return "\\w+";
201+
};
202+
c = 1;
203+
}
204+
while (c--)
205+
if (k[c]) p = p.replace(new RegExp("\\b" + e(c) + "\\b", "g"), k[c]);
206+
return p;
207+
}
208+
```
209+
210+
Finally, this is the _runtime_ of Packer. It takes the minified code (<code>"6 4(2){5 3=0;<wbr />7(5 1=0;1<2.8;++1)<wbr />3+=2[1];<wbr />9 3}<wbr />a.b(4(c,d,e))"</code>) and the list of keywords (<code>"|i<wbr />|arguments<wbr />|result<wbr />|sum<wbr />|var<wbr />|function<wbr />|for<wbr />|length<wbr />|return<wbr />|console<wbr />|log<wbr />|10<wbr />|20<wbr />|30"</code>) – and replaces each identifier in the first string with the corresponding keyword from the second string. Once it’s done, it returns the unminified code string:
211+
212+
```javascript{wordWrap: true}
213+
'function sum(arguments){var result = 0;for(var i=0;i<arguments.length;++i)result+=arguments[i];return result;}console.log(sum(10, 20, 30))';
214+
```
215+
216+
which is then passed into `eval()` and executed.
217+
218+
:::note
219+
The implementation becomes a little more complicated if the code is longer – mostly because `someNumber.toString(radix)` doesn’t accept radixes larger than 36, so it needs extra logic to handle more than 36 keywords. But it’s still essentially the same. Look, tho, dynamically generated runtime!
220+
:::
221+
222+
Yay! We’ve gotten through how Packer works. Now, the key question is:
223+
224+
# Is Packer Effective?
225+
226+
JavaScript tools went a long way since 2007. So how does Packer compare to the latest minifiers?
227+
228+
Let’s compare it by minifying jQuery 1.3.2. With the latest 2025 Terser, it gets down to 54 KB:
229+
230+
![Image.png](https://res.craft.do/user/full/2ece4b12-4ceb-0b26-7909-17063832b522/doc/035FFCB9-A8B2-447D-ABAB-5F009423C20E/B3E8A67D-5D47-448E-AFD9-8227ED881C42_2/hfiTiJCy28nkYqJb0Hsto0vAKtGaDVBxbhZ0UfCd1iIz/Image.png)
231+
232+
With Packer, however, it gets down to 40 KB (14 KB less):
233+
234+
![Image.png](https://res.craft.do/user/full/2ece4b12-4ceb-0b26-7909-17063832b522/doc/035FFCB9-A8B2-447D-ABAB-5F009423C20E/2B24D07D-1915-458C-87BA-73DA6434778C_2/1WMu28Afb9hyUAhihh0gqxKd5BlfnXPnCuYPxVxNMRUz/Image.png)
235+
236+
...What?
237+
238+
Let’s check with another library, Three.js. Terser gets it down to 358 KB:
239+
240+
![CleanShot 2025-03-01 at 20.58.35@2x.png](https://res.craft.do/user/full/2ece4b12-4ceb-0b26-7909-17063832b522/doc/035FFCB9-A8B2-447D-ABAB-5F009423C20E/C6EF4FAF-1DED-4EE6-90E8-B17527035229_2/5LwywNhRqEXOT5TfY2oaFZMhfaKkQrNPVZFNoSHLp8Yz/CleanShot%202025-03-01%20at%2020.58.352x.png)
241+
242+
Packer gets it down to 266 KB (almost 100 KB less):
243+
244+
![CleanShot 2025-03-01 at 20.58.51@2x.png](https://res.craft.do/user/full/2ece4b12-4ceb-0b26-7909-17063832b522/doc/035FFCB9-A8B2-447D-ABAB-5F009423C20E/ED7BA7DE-BDCB-44A6-B058-7F16C4589566_2/FEmEiidcWkkUssMXBwIy7xpwlIcA1LMqSA525x7w0bQz/CleanShot%202025-03-01%20at%2020.58.512x.png)
245+
246+
How is Packer so effective?!
247+
248+
It turns out this has to do with Packer minifying not only variable names (which Terser does excellently), but also keywords, object fields, and similar strings that are unminifiable with normal minifiers. E.g., if you take this code:
249+
250+
```javascript
251+
const cat = { name: 'Biba', age: 3, color: 'black' };
252+
const dog = { name: 'Rex', age: 4, color: 'brown' };
253+
const bird = { name: 'Tweety', age: 2, color: 'yellow' };
254+
255+
console.log(cat, dog, bird);
256+
```
257+
258+
and minify it with Terser, you’ll get something like this:
259+
260+
<!-- prettier-ignore -->
261+
```javascript
262+
const a={name:"Biba",age:3,color:"black"}
263+
const b={name:"Rex",age:4,color:"brown"}
264+
const c={name:"Tweety",age:2,color:"yellow"}
265+
console.log(a,b,c)
266+
```
267+
268+
Packer, however, will get you down to this:
269+
270+
```javascript
271+
eval(function(p,a,c,k,e,r){...}(
272+
'0 7={1:"a",5:3,6:"b"}0 8={1:"c",5:4,6:"d"}0 9={1:"e",5:2,6:"f"}g.h(7,8,9)',
273+
18,
274+
18,
275+
'const|name||||age|color|cat|dog|bird|Biba|black|Rex|brown|Tweety|yellow|console|log'.split('|'),
276+
0,
277+
{}
278+
))
279+
```
280+
281+
Notice how `const`, `name`, `age`, and `color` (which are repeated over and over in the Terser version) get replaced with a single number in the Packer version? That‘s what ultimately helps the Packer version to be smaller.
282+
283+
:::sidenote[Of course, it’s not always Gzip – it [could also be Brotli or zstd](https://paulcalvano.com/2024-03-19-choosing-between-gzip-brotli-and-zstandard-compression/). But I’m not getting into the trenches here.]
284+
Does this mean you should use Packer as your minifier of choice? Absolutely not. Every modern properly configured server applies yet another level of compression to any text file it sends. This compression is called Gzip, and it [does the same thing Packer does](https://3perf.com/talks/web-perf-101/#http-brotli-1): deduplicates repeated strings. Except it’s _much_ more effective at that:
285+
286+
```shell
287+
# Terser
288+
$ cat ./jquery-1.3.2.terser.min.js | wc -c # Size before gzip
289+
55120
290+
$ cat ./jquery-1.3.2.terser.min.js | gzip-size # Size after gzip
291+
18.6 kB
292+
293+
# Packer
294+
$ cat ./jquery-1.3.2.packer.min.js | wc -c # Size before gzip
295+
41022
296+
$ cat ./jquery-1.3.2.terser.min.js | gzip-size # Size after gzip
297+
20.6 kB
298+
```
299+
300+
And this is just the size aspect. Packer relies on `eval()`, and `eval()` is terrible because it’s unsafe, [incompatible with Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#unsafe_eval_expressions), [disables some V8 optimizations](https://groups.google.com/g/v8-users/c/jlISWv1nXWU), and so on.
301+
302+
# Bonus: Packer is ES2015-Compatible
303+
304+
Fun fact: Packer is ES2015+-compatible! Packing
305+
306+
```javascript
307+
function* getAnimals() {
308+
yield { name: 'Biba', age: 3, color: 'black' };
309+
}
310+
311+
for await (const animal of getAnimals()) {
312+
console.log(animal);
313+
}
314+
```
315+
316+
produces:
317+
318+
```javascript
319+
eval(function(p,a,c,k,e,r){...}(
320+
'2*0(){4{5:"6",7:3,8:"9"}}a b(c 1 d 0()){e.f(1)}',
321+
16,
322+
16,
323+
'getAnimals|animal|function||yield|name|Biba|age|color|black|for|await|const|of|console|log'.split('|'),
324+
0,
325+
{}
326+
))
327+
```
328+
329+
which evaluates with no issues. This is surprising for a tool from 2007, and is also pretty amusing given that [it took years](https://github.com/mishoo/UglifyJS/issues/448) for UglifyJS – the then-most-popular minifier – to support ES2015.
330+
331+
How does Packer support ES2015+? Modern minifiers work by parsing a JavaScript file into [an abstract syntax tree](https://astexplorer.net/). Any new syntax they don’t recognize breaks that process. Packer, however, is incredibly simple; its implementation takes [less than a thousand lines of code](https://github.com/tholu/php-packer/blob/master/src/Packer.php), and the only thing it does is string manipulation. For this tool, any keywords – no matter if from ES3 or from ES2018 – are just words to replace! So as long as you don’t rely on [automatic semicolon insertion](https://en.wikibooks.org/wiki/JavaScript/Automatic_semicolon_insertion) a bit too much:
332+
333+
```javascript
334+
function* getAnimals() {
335+
yield { name: 'Biba', age: 3, color: 'black' };
336+
}
337+
338+
for await (const animal of getAnimals()) {
339+
console.log(animal);
340+
}
341+
342+
// Gets packed into (roughly)
343+
//
344+
// function*getAnimals(){yield{name:"Biba",age:3,color:"black"}}
345+
// for await(const animal of getAnimals()){console.log(animal)}
346+
//
347+
// and works.
348+
//
349+
// But
350+
351+
function* getAnimals() {
352+
yield { name: 'Biba', age: 3, color: 'black' }; // no semicolon here
353+
yield { name: 'Rex', age: 4, color: 'brown' }; // no semicolon here either
354+
yield { name: 'Tweety', age: 2, color: 'yellow' };
355+
}
356+
357+
for await (const animal of getAnimals()) {
358+
console.log(animal);
359+
}
360+
361+
// gets packed into (roughly)
362+
//
363+
// function*getAnimals(){yield{name:"Biba",age:3,color:"black"}yield{name:"Rex",age:4,color:"brown"}yield{name:"Tweety",age:2,color:"yellow"}}
364+
// for await(const animal of getAnimals()){console.log(animal)}
365+
//
366+
// and crashes with
367+
//
368+
// Uncaught SyntaxError: Unexpected identifier 'yield'
369+
//
370+
// because you need a `;` after the `yield`s
371+
```
372+
373+
...Packer will just work.

0 commit comments

Comments
 (0)