Skip to content

perf(export): replace jszip with client-zip - 302 -> 211 kB bundle - #55

Merged
wormeyman merged 1 commit into
mainfrom
perf/client-zip
Jul 30, 2026
Merged

perf(export): replace jszip with client-zip - 302 -> 211 kB bundle#55
wormeyman merged 1 commit into
mainfrom
perf/client-zip

Conversation

@wormeyman

Copy link
Copy Markdown
Collaborator

The Download ZIP button bundles three short text files - the two Factorio JSON documents the headless CLI consumes (--map-gen-settings / --map-settings) plus the exchange string. It was costing 97 kB of the shipped bundle to do that.

Why jszip was so expensive

Its browser field resolves ./lib/index to dist/jszip.min.js, a 97.6 kB pre-minified browserify bundle. That is an opaque IIFE with its own module registry, so it cannot be tree-shaken - and the pako 1.0.11 baked inside it cannot dedupe against the app's pako 3.0.1. The app was shipping two pako majors, plus readable-stream, setimmediate and eight more Node shims, to write three text files.

Measured, by building both ways

raw gzip
before (jszip) 302.07 kB 99.81 kB
after (client-zip) 211.44 kB 73.11 kB
delta -90.63 kB (-30%) -26.70 kB (-27%)

Isolating the libraries: a build with the zip feature stubbed out entirely is 204.95 kB, so client-zip costs 6.49 kB where jszip cost 97.1 kB - roughly 15x smaller for this use.

Confirmed gone rather than assumed. Marker counts in the shipped bundle:

marker before after
JSZip / jszip 2 / 1 0 / 0
setImmediate 1 0
readable-stream 1 0
_tr_stored_block (pako deflate) 1 0
incorrect header check (pako) 2 1 ✅ codec's pako 3.0.1 remains

vp build still prints no warnings at all, which is the standing invariant.

The output is materially unchanged - and jszip wasn't compressing either

Worth recording, because it explains why the pako payload was pure waste: both writers emit Stored entries at 0%. generateAsync without an explicit compression option does not deflate.

Length   Method    Size  Cmpr    Name
   2936  Stored     2936   0%   map-gen-settings.json
   3806  Stored     3806   0%   map-settings.json
    798  Stored      798   0%   Default.txt

The downloaded archive goes 7888 → 7936 bytes (+48, +0.6%) - central-directory layout, not a compression regression.

jszip stays, as the test oracle

It moves to devDependencies and remains the reader in test/zipExport.spec.ts. Deliberate: checking a client-zip archive with client-zip would prove self-consistency, not correctness.

Validated against a third implementation too - Info-ZIP's unzip -t:

testing: map-gen-settings.json    OK
testing: map-settings.json        OK
testing: Default.txt              OK
No errors detected in compressed data.

Gotcha for the next person: pnpm add -D will not relocate a package whose installed version already satisfies the range - it prints "Already up to date" and leaves it in dependencies. The move was made by editing package.json directly, then pnpm install.

Test changes

Changing the writer means "the entry exists" stops being a sufficient assertion, so the spec gains two cases: one parses both JSON entries back and checks real fields, and asserts the exchange string survives verbatim against the fixture; one covers preset-name-derived entry naming and the exact entry set. 1249 → 1251 tests.

What this does not buy

Not fresher maintenance. client-zip's last human commit is 2025-03-14 - the more recent repo push is a Dependabot branch. This swap buys bundle size and API fit. What it does improve is backlog health: 8 open issues against jszip's 412 open issues and 40 unanswered PRs (newest, #967 from 2026-07-15, still has zero comments).

client-zip is MIT, zero-dependency, browser-native ESM, and requires ES2020 BigInt - fine against this project's ESNext target.

Gate

pnpm run verify exit 0 - 314 files formatted, 301 clean, app 1251 passed / 3 skipped, worker 12, container 3.

Follows the dependency audit of 2026-07-29.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GjMSePtN9aTgr8EtZp66k1

The Download ZIP button bundles three short text files (the two Factorio
JSON documents plus the exchange string) for the headless CLI route. It
was costing 97 kB of the shipped bundle to do it.

`jszip`'s `browser` field resolves `./lib/index` to `dist/jszip.min.js`, a
97.6 kB pre-minified **browserify** bundle. That is an opaque IIFE with its
own module registry, so it cannot be tree-shaken, and the pako 1.0.11 baked
inside it cannot dedupe against the app's pako 3.0.1. The app was shipping
two pako majors plus `readable-stream`, `setimmediate` and eight more Node
shims in order to write three text files.

Measured, by building both ways:

| | raw | gzip |
| --- | --- | --- |
| before (jszip) | 302.07 kB | 99.81 kB |
| after (client-zip) | 211.44 kB | 73.11 kB |
| delta | **-90.63 kB (-30%)** | **-26.70 kB (-27%)** |

Isolating the libraries: a build with the zip feature stubbed out entirely
is 204.95 kB, so `client-zip` costs **6.49 kB** where `jszip` cost 97.1 kB
- about 15x smaller for this use.

Confirmed gone from the bundle rather than assumed: `JSZip`, `jszip`,
`setImmediate`, `readable-stream` and `_tr_stored_block` markers all drop
to zero, and pako's `incorrect header check` string goes from 2 occurrences
to 1 - the codec's pako 3.0.1 remains, the duplicate is gone. `vp build`
still prints no warnings at all, which is the standing invariant.

**The output is materially unchanged, and the reason is worth recording:
jszip was not compressing either.** Both writers emit `Stored` entries at
0% - `generateAsync` without an explicit `compression` option does not
deflate. So the whole pako 1.x payload was dead weight for this path. The
downloaded archive goes 7888 -> 7936 bytes (+48 bytes, +0.6%), which is the
central-directory layout differing slightly, not a compression regression.

`jszip` moves to devDependencies and stays the **reader** in
`test/zipExport.spec.ts`. That is deliberate: checking a client-zip archive
with client-zip would prove self-consistency, not correctness. Validated
against a third implementation too - `unzip -t` (Info-ZIP) reports "No
errors detected" and lists all three entries with correct names, sizes and
CRCs.

Note `pnpm add -D` will not relocate a package whose installed version
already satisfies the range - it prints "Already up to date" and leaves it
where it was - so the dependencies -> devDependencies move was made by
editing `package.json` directly and re-running `pnpm install`.

The spec gains two cases, because changing the *writer* means entry
presence is no longer a sufficient assertion: one parses both JSON entries
back and checks real fields, and asserts the exchange string survives
verbatim against the fixture; one covers preset-name-derived entry naming
and the exact entry set. 1249 -> 1251 tests.

Not adopted for currency: `client-zip`'s last human commit is 2025-03-14
(the newer repo push is a Dependabot branch). This swap buys bundle size
and API fit, not fresher maintenance. What it does improve is backlog - 8
open issues against jszip's 412 and 40 unanswered PRs.

Gate: `pnpm run verify` exit 0 - 314 files formatted, 301 clean, app 1251
passed / 3 skipped, worker 12, container 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjMSePtN9aTgr8EtZp66k1
@wormeyman
wormeyman merged commit 0e484c0 into main Jul 30, 2026
1 check passed
wormeyman added a commit that referenced this pull request Jul 30, 2026
…RULE, not the fields (#57)

Verified locally and in CI, and the anti-vacuity control was tested by breaking it.

## The `+3` control is genuinely load-bearing - proven, not assumed
This repo has a documented history of confirmations that turned out vacuous, so I sabotaged the substitution rather than trusting the assertion. Replacing the game-field lookup with one that never matches - i.e. simulating every lookup silently falling through to our own field:

| test | with substitution dead |
| --- | --- |
| "substituting the game's own elevation and cliffiness does not move a single cell" | **still passes** |
| "and the substitution really is live - a `+3` elevation bias does move cells" | **FAILS**: `AssertionError: expected 0 to be greater than 5` |

So the headline assertion *would* have passed vacuously, and the `+3` control is exactly what catches it. Restored and re-confirmed 9/9 green. `expect(game.size).toBeGreaterThan(500)` is a second, independent guard against an empty placement set making the equality trivial.

## Provenance
`test/fixtureProvenance.spec.ts` passes (4/4). Both new fixtures carry entries with `factorioVersion: "2.1.12"` and concrete `evidence` naming the capture script and recording that `refs:sync --check` reported in-sync at capture time - so neither lands as `unknown`, and the spec's `unknown` cap is not touched.

## Gate
- Local `pnpm run verify` exit **0** in 60s on the exact tree that lands (`5399b6f`): format 316, lint+type 301, `check:vue` clean, app **1255 passed / 3 skipped**, worker 12.
- CI `verify`: **pass** (4m41s). The branch was updated **twice** so the run tests the real target - it now contains #51, #52, #55 and #59, not the older `main` the first run saw.
- No `src/` change, so nothing shipped to users; the new spec needs no Factorio at runtime because the fixtures are committed, which is why CI can run it.

## Two notes, neither blocking
- The description reads as though `test/vulcanusOreCliffSeparation.spec.ts` is new. It is not - it already existed on `main` at 222 lines and this extends it to 597 (3 -> 7 `it` blocks at the top level, 9 tests total), which is why the file count stays 143 and the suite moves +4 rather than +9.
- Re-deriving the binary reading and the statistics was explicitly out of scope for this pass, so the `0x1016229b4` / `0x101625038` disassembly claims and the Poisson figures are **recorded as the author's measurements, not independently reproduced here**. What was checked is that the code is gated, the fixtures have provenance, and the assertions can fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant