@kirinsaninc/lhats
v0.1.0
Published
Pure TypeScript LZH (LHA) archive reader for Node.js and browsers. Zero dependencies, zero Node built-ins. Read-only by design.
Maintainers
Readme
lhats
Read LZH (LHA) archives in Node.js and the browser. Pure TypeScript, zero dependencies.
This library reads archives. It does not create them. → Why read-only
Why
The purpose of this library is to keep old archives readable.
LZH was the de facto standard archive format in Japan from the late 1980s through
the 1990s. A great deal of material from that era still exists only as .lzh,
while the tools that can open it are DOS and Windows programs that grow harder to
run every year.
lhats is about 2,000 lines of TypeScript. It runs in any JavaScript environment
where a Uint8Array works — Node.js, the browser, Cloudflare Workers, anywhere.
Why read-only
In 2010, Micco — the author of UNLHA32.DLL, the most widely used LZH implementation on Windows — published an advisory about vulnerabilities in LZH header handling, ended development, and called for an end to the creation of new LZH archives. We take seriously what it meant for the person who carried the format's principal implementation to decide it was time to retire it.
Given that history, offering a way to author new LZH archives in 2026 is simply not something we would consider.
But "stop making new ones" and "let the existing ones become unreadable" are two entirely different things, and only the first was ever asked for.
This library exists for the sake of what already exists.
Install
npm install @kirinsaninc/lhatsUsage
import { LhaReader, Uint8ArrayReader, Uint8ArrayWriter } from "@kirinsaninc/lhats";
const reader = new LhaReader(new Uint8ArrayReader(archiveBytes));
for (const entry of await reader.getEntries()) {
if (entry.directory) continue;
const data = await entry.getData(new Uint8ArrayWriter());
// ...
}
await reader.close();Writer and Uint8ArrayWriter are the sink for decompressed output, not a
means of assembling an archive.
Shift_JIS (CP932) filenames — read this first
LZH headers store filenames as raw bytes with no encoding field. Archives created in Japan are almost always CP932. lhats does not guess the encoding. Pass it a decoder.
// TextDecoder("shift_jis") is available in Node and in every major browser
const reader = new LhaReader(new Uint8ArrayReader(bytes), {
filenameDecoder: (b) => new TextDecoder("shift_jis").decode(b),
});Without this you will get mojibake on the majority of real-world archives. The default decoder tries UTF-8 and falls back to byte-preserving Latin-1.
Damaged archives
CRC-16 mismatches throw by default.
const reader = new LhaReader(source, {
checkCrc: "warn", // to read what you can from a damaged archive
onCrcMismatch: ({ filename }) => console.warn(`damaged: ${filename}`),
});Supported methods
| Method | Notes |
|---|---|
| -lh0- | stored |
| -lh1- | LZSS 4 KiB + adaptive Huffman (LHarc era) |
| -lh4- | 4 KiB window |
| -lh5- | 8 KiB window — the most common |
| -lh6- | 32 KiB window |
| -lh7- | 64 KiB window |
| -lz4- | LArc, stored |
| -lhd- | directory entries |
Header levels 0, 1 and 2. Extended headers 0x01 (long filename) and 0x02
(directory path) are read, so filenames written by Windows 95-era archivers
(UNLHA32.DLL, LHA32, LHMelt) and by LHa for UNIX come through intact.
MS-DOS-era truncation to 8.3 form such as PROGRA~1 was correct for its time,
and no attempt is made to undo it.
Verification
Fixtures are not produced by lhats. They are generated by external
implementations and checked by a second one. See
test/fixtures/README.md.
| Producer | What it proves |
|---|---|
| Original LHA 2.55 (MS-DOS, Haruyasu Yoshizaki, 1992), run under DOSBox-X | -lh0-/-lh1-/-lh5- × header levels 0/1/2 decode byte-identically to what the original compressed |
| LHa for UNIX | CP932 filenames, long filenames, nested Japanese directory paths |
| lhasa / libarchive | every committed fixture is accepted by independent implementations |
Path traversal, truncated streams, inflated size fields, invalid Huffman code-length tables, CRC mismatches, unsupported methods — all of these raise an error rather than returning something that merely looks like data.
Limitations
- Header level 3 is not supported.
- Extended header
0x42(64-bit sizes) is not supported — members over 4 GiB will not read. - Extended headers
0x3F(comment),0x40/0x41(Windows timestamps),0x50–0x54(Unix permissions, uid/gid) are skipped. - Symbolic links are not supported.
- Methods
-lh2-,-lh3-,-lh8-,-lhx-, LArc-lzs-/-lz5-, PMarc-pm0-/-pm2-are not supported. - Encrypted, multi-volume and self-extracting archives are not supported.
- No streaming: the whole archive is held in memory, and each member is
materialised in full before it reaches your
Writer. - Decompressed size is capped at 1 GiB per member.
- The level-2 header CRC (extended header
0x00) is skipped — neither read nor verified.
Acknowledgements
This library stands on work done by others. The full text is in NOTICE.md, but in particular:
Haruyasu Yoshizaki — author of LHarc, LHA and lzhuf.c. The -lh1-
implementation here derives directly from his work, and it is verified against
archives his LHA 2.55 actually produced.
Haruhiko Okumura — who shared the knowledge of this field widely, through
his implementations of LZSS and LZARI and through writing that made them
approachable. His 1989 English translation of the comments in lzhuf.c opened
the way for an implementation born in Japan to reach the rest of the world.
The authors of LHA archivers through the 1990s and 2000s — LHa for UNIX, UNLHA32.DLL, LHMelt, and countless front ends and DLLs. LZH remained a format that could be read because so many people kept building tools that could read one another's output.
License
MIT © kirinsan.inc
