diff --git a/package.json b/package.json index b50cfa9e..a7134871 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@vue/devtools-api": "^8.2.1", - "jszip": "^3.10.1", + "client-zip": "^2.5.0", "pako": "^3.0.1", "pinia": "^4.0.2", "vue": "^3.5.40" @@ -35,6 +35,7 @@ "@vue/test-utils": "^2.4.11", "concurrently": "^10.0.4", "happy-dom": "^20.11.1", + "jszip": "^3.10.1", "typescript": "^6.0.3", "vite-plus": "^0.2.6", "vue-tsc": "^3.3.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7812ed3d..ae158e4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,9 +213,9 @@ importers: '@vue/devtools-api': specifier: ^8.2.1 version: 8.2.1 - jszip: - specifier: ^3.10.1 - version: 3.10.1 + client-zip: + specifier: ^2.5.0 + version: 2.5.0 pako: specifier: ^3.0.1 version: 3.0.1 @@ -238,6 +238,9 @@ importers: happy-dom: specifier: ^20.11.1 version: 20.11.1 + jszip: + specifier: ^3.10.1 + version: 3.10.1 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1548,6 +1551,9 @@ packages: cjs-module-lexer@1.2.3: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + client-zip@2.5.0: + resolution: {integrity: sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -3148,6 +3154,8 @@ snapshots: cjs-module-lexer@1.2.3: {} + client-zip@2.5.0: {} + cliui@9.0.1: dependencies: string-width: 7.2.0 diff --git a/src/io/zipExport.ts b/src/io/zipExport.ts index 88b34cd6..22a49d14 100644 --- a/src/io/zipExport.ts +++ b/src/io/zipExport.ts @@ -1,4 +1,4 @@ -import JSZip from "jszip"; +import { downloadZip } from "client-zip"; import { encodeExchangeString } from "../codec/mapExchangeString"; import { presetToEncodable } from "../model/convert"; import type { Preset } from "../model/types"; @@ -7,11 +7,42 @@ import { toMapGenSettingsJson, toMapSettingsJson } from "./jsonExport"; /** * Bundle a preset's two Factorio JSON documents plus its map-exchange string * into a single downloadable ZIP `Blob`. + * + * The two JSON files are what the game's own CLI consumes - `factorio --create + * --map-gen-settings --map-settings ` - so this is the + * headless/dedicated-server route for a preset. The `.txt` carries the exchange + * string for the in-game map-generator dialog. + * + * Backed by `client-zip` rather than `jszip`, which is a bundle-size decision: + * jszip's `browser` field resolves to a 97.6 kB pre-minified *browserify* + * bundle - an opaque IIFE that cannot be tree-shaken and that carries its own + * copy of pako 1.x, so the app shipped two pako majors plus readable-stream and + * setimmediate shims in order to write three short text files. `client-zip` is + * zero-dependency browser-native ESM. + * + * `jszip` remains a devDependency and is *deliberately* still the reader in + * `test/zipExport.spec.ts`. Checking our own writer with our own reader would + * be self-consistent rather than correct; an independent, battle-tested reader + * is the whole point of that test. + * + * Entry timestamps default to "now", which is what jszip did too, so the + * archive is not byte-reproducible across runs. That is fine and always was: + * byte-exactness is the *exchange string's* invariant, enforced in + * `src/codec/`. A ZIP is read by whatever unzip tool opens it. */ export async function buildZip(preset: Preset): Promise { - const zip = new JSZip(); - zip.file("map-gen-settings.json", JSON.stringify(toMapGenSettingsJson(preset), null, 2)); - zip.file("map-settings.json", JSON.stringify(toMapSettingsJson(preset), null, 2)); - zip.file(`${preset.name}.txt`, encodeExchangeString(presetToEncodable(preset))); - return zip.generateAsync({ type: "blob" }); + return downloadZip([ + { + name: "map-gen-settings.json", + input: JSON.stringify(toMapGenSettingsJson(preset), null, 2), + }, + { + name: "map-settings.json", + input: JSON.stringify(toMapSettingsJson(preset), null, 2), + }, + { + name: `${preset.name}.txt`, + input: encodeExchangeString(presetToEncodable(preset)), + }, + ]).blob(); } diff --git a/test/zipExport.spec.ts b/test/zipExport.spec.ts index a9168e67..5e215b8a 100644 --- a/test/zipExport.spec.ts +++ b/test/zipExport.spec.ts @@ -7,18 +7,54 @@ import fixtures from "./fixtures/builtin-presets.json"; const presets = fixtures.presets as Record; +function defaultPreset() { + return presetFromDecoded("Default", decodeExchangeString(presets["Default"] as string), true); +} + describe("buildZip", () => { + // `buildZip` writes with `client-zip`; this reads with `jszip`, which is a + // devDependency kept for exactly this purpose. Reading our own archive with + // our own writer's library would only prove self-consistency. it("bundles the two JSON files and the exchange string", async () => { - const preset = presetFromDecoded( - "Default", - decodeExchangeString(presets["Default"] as string), - true, - ); - const blob = await buildZip(preset); + const blob = await buildZip(defaultPreset()); const zip = await JSZip.loadAsync(blob); expect(zip.file("map-gen-settings.json")).not.toBeNull(); expect(zip.file("map-settings.json")).not.toBeNull(); const txt = await zip.file("Default.txt")?.async("string"); expect(txt?.startsWith(">>>")).toBe(true); }); + + it("round-trips each entry's bytes intact, not merely its name", async () => { + const preset = defaultPreset(); + const zip = await JSZip.loadAsync(await buildZip(preset)); + + // Entry presence says nothing about whether the payload survived the + // writer. Parse it back and check real fields. + const gen = JSON.parse((await zip.file("map-gen-settings.json")!.async("string")) as string); + expect(gen.width).toBe(preset.width); + expect(gen.height).toBe(preset.height); + expect(gen.autoplace_controls).toBeTypeOf("object"); + expect(Object.keys(gen.autoplace_controls).length).toBeGreaterThan(0); + + const map = JSON.parse((await zip.file("map-settings.json")!.async("string")) as string); + expect(map.pollution).toBeTypeOf("object"); + expect(map.enemy_evolution).toBeTypeOf("object"); + + // The exchange string must survive verbatim - it is the one artifact in + // the archive with a byte-exactness invariant behind it. + const txt = (await zip.file("Default.txt")!.async("string")) as string; + expect(txt).toBe(presets["Default"]); + }); + + it("names the text entry after the preset", async () => { + const preset = defaultPreset(); + preset.name = "My Custom Preset"; + const zip = await JSZip.loadAsync(await buildZip(preset)); + expect(zip.file("My Custom Preset.txt")).not.toBeNull(); + expect(Object.keys(zip.files).sort()).toEqual([ + "My Custom Preset.txt", + "map-gen-settings.json", + "map-settings.json", + ]); + }); });