# adm-zip: a zero-dependency zip library that ships its own source and its own spec

> adm-zip implements the ZIP format in plain JavaScript with no runtime dependencies at all, publishes its source files as the package entry point, and keeps the ZIP format specification in the repository as APPNOTE.md. It is at version 0.6.1, its package.json declares support for Node 14, and its own test stack cannot run on Node 14.

**cthackers/adm-zip** — A Javascript implementation of zip for nodejs. Allows user to create or extract zip files both in memory or to/from disk

- Repository: https://github.com/cthackers/adm-zip
- Stars: 2,183 · Forks: 399
- Language: JavaScript
- License: MIT
- Published: 2026-09-30 · Updated: 2026-09-30 · Language: en
- Canonical page: https://hysenlabs.com/projects/cthackers-adm-zip

## No dependencies, and the format spec kept in the repository

The README states it in a sentence that is either a boast or a warning, depending on what you need: there are no other nodeJS libraries that ADM-ZIP is dependent of.

The package.json confirms it structurally. There is no dependencies key at all, and there is no optionalDependencies and no peerDependencies. Everything the library needs comes from Node itself. For a library whose job is to parse a binary container format, that is a meaningful property rather than a party trick, because archive handling is a classic place for a dependency tree to appear: a compression library, a date parser, a codepage table, a buffer helper. None of those are here.

The reason is visible in the repository listing. There is an APPNOTE.md at the root, which is the ZIP format's own specification document, the one archive implementers are supposed to work from. Keeping it in the tree next to the implementation is what a from-the-spec implementation looks like, and it is also what makes the library auditable. An evaluator who wants to know what the parser will accept and what it will do with a malformed structure has both the implementation and the document it was written against in the same repository, rather than having to trust that a transitive dependency got the details right.

The cost of no dependencies shows up in the devDependencies, and the interesting entry there is iconv-lite. It is a dev-only dependency, which means it is used to build test archives rather than to decode them at runtime. A zip archive can carry filenames in encodings other than UTF-8, and decoding those needs a codepage table, which is exactly the kind of data a library might be tempted to depend on. This one does not, so filename decoding runs on whatever Node's Buffer offers and the legacy-encoding paths are not covered by a runtime dependency. That is a reasonable trade for a zero-dependency goal and a real limit on which archives it handles cleanly.

The source layout follows the format. adm-zip.js is the entry point, zipFile.js is the archive container, zipEntry.js is a single member record, and headers/, methods/ and util/ are the parts, the per-entry operations and the shared helpers.

## The manifest says Node 14 and the test stack says otherwise

The package.json declares a floor that its own development toolchain contradicts, and it is worth reading the two halves together.

```json
  "engines": {
    "node": ">=14.0"
  },
  "overrides": {
    "mocha": {
      "chokidar": "^4.0.3"
    }
  },
```

The engines field says Node 14.0 or newer. Node 14 reached end of life in April 2023, so the floor is a runtime that receives no security updates and that most organisations have removed from their build images.

Now the devDependencies: mocha at ^12.0.0, chai at ^6.2.2, prettier at ^3.8.1, rimraf at ^3.0.2. Those are current major versions, and none of them supports Node 14. Running npm install on Node 14 with this manifest produces a development environment that cannot execute the test suite, because the test runner itself will not install or run there.

That is not necessarily a defect in the library. The engines field describes what consumers may run, and devDependencies describe what contributors may run, and a project can legitimately support an old runtime for consumers while requiring a new one for development. But the two numbers are in the same file, and nothing in the file says the split is deliberate, so the natural reading is that engines is simply stale.

For a consumer the practical question is which number to believe. The safe interpretation is that the code is written in a conservative style and will probably load on old Node, because the README's own examples use var and function rather than const and arrow functions, and the main entry point is a plain CommonJS file with no transpilation. The published package is the source, so what you run is exactly what the author wrote. That is unusual and mostly good: there is no build-output indirection between the tagged source and the executing code.

The other half of the answer is that you probably do not have the choice. If your application is on a current Node, the floor is irrelevant. If you are on Node 14, you have larger problems than a zip library, and this one is at least honest about being zero-dependency enough not to add to them.

## The chokidar override is the interesting line in the manifest

An overrides block in a library with no dependencies looks contradictory until you notice what it overrides.

The override is on mocha, and it forces chokidar to ^4.0.3. chokidar arrives through mocha, and mocha arrives as a devDependency, so this pin has no effect on anyone who installs adm-zip. It exists solely for contributors running the test suite, and it exists because the version of chokidar that mocha would otherwise resolve had a known problem.

The shape of the fix is the diagnostic. A transitive advisory on a file-watcher dependency is not something a maintainer can fix in their own code, because they do not own the dependency chain. The available responses are to upgrade the parent, to add an override, or to do nothing. This project added the override, and pinned it to a major version rather than a range, which is the conservative form.

It is a small signal and it is a real one. A maintainer who writes an overrides block to keep a test-only dependency on a patched major version is paying attention to the advisories in their own tree, even when that tree contributes nothing to the shipped artefact. Compare that with the stale engines field in the same file: the security-relevant part is current and the cosmetic part is not, which is a common and forgivable ordering of priorities.

The rest of the development setup is conventional and modern. Tests are mocha with the spec reporter, configured by a .mocharc.yml, and assertions are chai, which at version 6 is the ESM-first major. Formatting is prettier at version 3 with a .prettierrc.json and a .prettierignore, and there is a .editorconfig and a .gitattributes for line endings and editor behaviour. The scripts block exposes npm test, npm run test:format for a check-only pass, and the format scripts that wrap prettier over js, yml and json files.

The test fixtures also tell you something about the scope of the library. There is a test/ directory at the top level, and the mocha invocation has no path argument, so it discovers the suite from the configuration. With iconv-lite as a fixture-building dependency and APPNOTE.md as the reference, the suite is evidently aimed at archives with awkward properties rather than at the happy path, which is the right target for a binary format parser.

## The published package is the source, and main is a .js file at the root

There is no build step in this project, and the package.json says so by listing exactly what ships.

```json
  "files": [
    "adm-zip.js",
    "headers",
    "methods",
    "util",
    "zipEntry.js",
    "zipFile.js",
    "types.d.ts",
    "LICENSE"
  ],
  "main": "adm-zip.js",
  "types": "types.d.ts",
```

Every entry in the files array is a source file or a directory of source files. There is no dist, no lib, no build output, no transpiled bundle and no minified artefact. The main field points at adm-zip.js, which is the file at the repository root, and the types field points at a hand-written types.d.ts.

Three consequences follow, and they cut in different directions.

The good one is provenance. What you install is the code that was tagged, character for character, with no intermediate toolchain that could have transformed it. For a library that parses untrusted binary input, being able to read the bytes that will run is worth something.

The cost is module format. With no type field in the package.json, Node treats the package as CommonJS, and the examples confirm it with require calls. There is no exports map either, which means deep imports such as require("adm-zip/zipEntry") resolve, and which also means there is no place to declare a conditional export if the project ever wanted to ship an ESM wrapper. An ESM-first application can still import this, through Node's interop, but it gets a CommonJS module and a default export rather than a native one.

The third is that the code style in the README is the code style in the package. The examples use var, function and a forEach callback, which is not stylistic nostalgia, it is the shipped source. The documentation is not showing you a simplified version of the library, it is showing you the library.

The typing situation is the one soft spot in an otherwise honest manifest. A hand-written types.d.ts at version 0.6.1 of a library that has been at 0.x for a long time will be narrower than the runtime behaviour, and the README does not mention types at all. If you are in TypeScript, the declaration file is the contract, and the contract is the thing to read before assuming a method exists or returns what you expect.

## extractEntryTo's maintainEntryPath is the parameter that decides the blast radius

The basic usage example packs a lot into four lines, and one of the arguments is doing more work than the comment beside it suggests.

```javascript
zip.extractEntryTo(/*entry name*/ "some_folder/my_file.txt", /*target path*/ "/home/me/tempfolder", /*maintainEntryPath*/ false, /*overwrite*/ true);
// extracts everything
zip.extractAllTo(/*target path*/ "/home/me/zipcontent/", /*overwrite*/ true);
```

maintainEntryPath, set to false in the example, controls whether the directory structure recorded inside the archive is recreated under the target path. With it true, extracting an entry named some_folder/my_file.txt into /home/me/tempfolder produces /home/me/tempfolder/some_folder/my_file.txt. With it false, it produces /home/me/tempfolder/my_file.txt.

That is the flag that decides whether an extraction is confined to the directory you named. Archive formats do not constrain the paths an entry can carry, so a library that faithfully reproduces them will write wherever the archive says, and the only thing standing between that and an extraction outside your target is whether the caller asks for the structure to be preserved. The overwrite argument is the second half of the same question, controlling whether an existing file is replaced.

The README does not frame either argument as a security consideration. Both appear as inline comments, /*maintainEntryPath*/ and /*overwrite*/, in an example, and the overwrite flag is set to true in both extract calls without comment. That is normal for example code and it is the wrong default to copy when the archive came from somewhere you do not control.

The rest of the example is worth reading for the same reason. getEntries returns an array of ZipEntry records and the comment notes that a password parameter should be added if entries are password protected, so encrypted archives are a supported case rather than an error. getData() returns a buffer, toString("utf8") is applied explicitly by the caller, readAsText is the convenience wrapper for a named entry, and addFile takes a comment string as its third argument alongside the name and the buffer.

The API is small, flat and synchronous. Every method here is a plain call that returns data or writes to disk, which is the right shape for a format library and the reason there is no callback or promise anywhere in the example. For a large archive read fully into memory that is a memory profile you have to think about, and the alternative of streaming is not present in this API.

## Electron original-fs moved from implicit to an option

The Electron section of the README is short and tells a small migration story that is more interesting than it first appears.

The claim is that ADM-ZIP has supported electron original-fs for years without any user interactions, and that it causes problems with bundlers like rollup. For continuing support of original-fs or any other custom file system module, there is a way to specify your module by an fs option in the ADM-ZIP constructor.

```javascript
const AdmZip = require("adm-zip");
const OriginalFs = require("original-fs");

// reading archives
const zip = new AdmZip("./my_file.zip", { fs: OriginalFs });
```

The mechanism is an options object with an fs key that takes a module. The constructor signature is the same in the main example, where a path is passed with no options, so this is purely additive.

The story the note tells is worth reconstructing because it is a common shape in libraries with a long tail of platform integrations. The library was written for Node, and its fs calls worked in Electron for years by accident, because Electron's fs and the real fs were compatible enough at the time. Then the bundlers arrived. rollup, webpack and their ilk statically analyse require calls, and a library that reaches for a module it expects to be ambient rather than injected is exactly the kind of thing that breaks under static analysis. Rather than break the bundler users, the maintainer made the dependency explicit: pass the module you want and the bundler can see it.

That is the right fix, and it is also the general pattern for a zero-dependency library in 2026. A package with no dependencies still depends on the shape of the platform it runs on, and the way to stop surprising people is to make every ambient assumption into a parameter.

What the README does not do is document the option beyond the example. There is no list of which methods accept it, no statement of whether it applies to reads, writes or both, and no mention of what the default resolves to on a non-Electron platform. So if you are in Electron, the example tells you what to write, and reading the source tells you how far it reaches.

## 0.6.1, an eighteenth patch, and a history file instead of a changelog

The release history tells a straightforward story with one detail that deserves attention.

The three most recent releases are v0.5.18 on 2026-06-29, v0.6.0 on 2026-07-10, and v0.6.1 on 2026-09-11. The last push was on 2026-09-11 at 10:30:51, which is the same moment as the v0.6.1 tag, so the default branch is sitting exactly on the newest release. The author is Nasca Iacob, and the package description credits the email address adm-zip@pm.me.

The detail is v0.5.18. Eighteen patch releases on a 0.5 line, and the eighteenth is dated June 2026, which tells you the 0.5 line was long-lived and heavily iterated. Then a minor bump to 0.6.0 in July, then a patch to 0.6.1 in September. That is a project that is actively maintained rather than merely alive, and the pre-1.0 number is being used the way npm intends, for a library whose API has not yet promised to be stable.

That pre-1.0 status is a real consideration for something that parses binary formats from untrusted sources. Semantic versioning's promise is that anything below 1.0 may break in a minor release, and 0.5 to 0.6 is exactly the boundary where that applies. A consumer pinning 0.6.1 gets the behaviour they tested; a consumer tracking 0.6 gets whatever the next minor decides.

The changelog situation is the other detail. There is a history.md at the repository root rather than a CHANGELOG.md, and the README does not link it. It is in the repository, which is better than nothing, and the release tag messages are bare version strings, so the history file is the only place the changes are written down.

The security posture is the last thing to check before adopting, and it is handled in a way that costs nothing. There is a SECURITY.md at the root and the README says to report security vulnerabilities privately and points at that file. A library that has had eighteen patch releases on one minor line and handles attacker-controlled binary input will eventually have an advisory, and a published private channel is the minimum you want to find. What SECURITY.md contains is not shown here, so the response process itself is unverified, but the intent is stated and the file exists.

The final item is the most consequential for a library in this position. The zip format has a long history of path-traversal problems in extractors, and the 0.x version number, the eighteen patches and the hand-written TypeScript definitions are all reasons to read the extraction path before you point it at an archive a stranger uploaded.

## Conclusion

Use adm-zip if you need to read or write zip archives in a Node service with no native modules and no dependency tree, and you control the archives you process. Do not use it as the only line of defence when extracting archives that came from users or from the internet, because the parameter that controls whether the archive's directory structure is recreated on extraction is the maintainEntryPath argument in extractEntryTo and extractAllTo, and the README passes it without comment. Do not assume the Node 14 floor in the manifest is meaningful; the declared engines value is stale and the real constraint is whatever your application already requires. Verify four things. Read the security policy, because the project asks for private reporting through SECURITY.md and a project that maintains one is worth reading before you file a public issue. Pin the version, because 0.6.1 is pre-1.0 and the 0.5 line reached an eighteenth patch. Check whether you need an ESM entry, since main points at a CommonJS source file and there is no exports map. And if you use Electron, read the fs option, because the original-fs behaviour that used to be implicit is now something you opt into. The deciding fact is that a zero-dependency archive parser is a smaller supply-chain surface than any alternative, and that is worth more than the convenience of a richer API.

## FAQ

### How do I install adm-zip and read a zip file?

Run npm install adm-zip, then require it and construct an archive from a path. Call getEntries() for an array of ZipEntry records, getData() on an entry for a buffer, readAsText() for a named entry as text, extractEntryTo() for one entry, or extractAllTo() for everything. Add a password parameter to getEntries if the entries are password protected.

### Does adm-zip have any dependencies?

No. The package.json has no dependencies, optionalDependencies or peerDependencies entry at all, and the README states there are no other nodeJS libraries that ADM-ZIP is dependent of. Everything comes from Node itself, and the ZIP format specification is kept in the repository as APPNOTE.md, which is the document the implementation is written against.

### Which Node versions does adm-zip support?

The manifest declares engines.node as >=14.0. That value is stale, since Node 14 is end of life and the devDependencies, mocha 12 and chai 6, cannot run on it. The published package is untranspiled CommonJS source written in a conservative var-and-function style, so it will probably load on older runtimes, but the declared floor should not be relied on.

### How do I use adm-zip in Electron with original-fs?

Pass the module through the fs option in the constructor: new AdmZip("./my_file.zip", { fs: OriginalFs }) with OriginalFs required from original-fs. The README notes that automatic support for original-fs used to work without user interaction but caused problems with bundlers like rollup, which is why the dependency is now explicit.

### What licence is adm-zip under and what version is it at?

MIT, with a LICENSE file at the repository root. The current version is 0.6.1, tagged 2026-09-11, and it is pre-1.0, so a minor release may break the API. The previous line reached an eighteenth patch at v0.5.18 before the 0.6.0 minor and the 0.6.1 patch.

## Sources

- [cthackers/adm-zip on GitHub](https://github.com/cthackers/adm-zip)
- [Issues](https://github.com/cthackers/adm-zip/issues)
- [License: MIT](https://github.com/cthackers/adm-zip/blob/master/LICENSE)
- [README](https://github.com/cthackers/adm-zip/blob/master/README.md)
- [Releases](https://github.com/cthackers/adm-zip/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/cthackers-adm-zip
