Skip to content

Commit e3bac3e

Browse files
fix: prevent portal generate hang on Node 22+ by extracting with adm-zip (#283) (#284)
1 parent 90fc5ff commit e3bac3e

3 files changed

Lines changed: 44 additions & 93 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@
5353
"@oclif/core": "^4.2.8",
5454
"@oclif/plugin-autocomplete": "^3.2.24",
5555
"@oclif/plugin-help": "^6.2.26",
56+
"adm-zip": "^0.5.17",
5657
"ansis": "^3.17.0",
5758
"async-mutex": "^0.5.0",
5859
"axios": "^1.8.1",
5960
"chokidar": "^3.6.0",
6061
"connect-livereload": "^0.6.1",
6162
"execa": "^9.6.0",
6263
"express": "^4.17.1",
63-
"extract-zip": "^2.0.1",
6464
"fast-levenshtein": "^3.0.0",
6565
"form-data": "^4.0.2",
6666
"fs-extra": "^11.3.0",
@@ -86,6 +86,7 @@
8686
"@oclif/test": "^4.1.13",
8787
"@semantic-release/changelog": "^6.0.3",
8888
"@semantic-release/git": "^10.0.1",
89+
"@types/adm-zip": "^0.5.8",
8990
"@types/base-64": "^1.0.0",
9091
"@types/chai": "^4.3.20",
9192
"@types/connect-livereload": "^0.5.32",

pnpm-lock.yaml

Lines changed: 19 additions & 74 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/infrastructure/zip-service.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'fs';
22
import yazl from 'yazl';
3-
import extract from 'extract-zip';
3+
import AdmZip from 'adm-zip';
44
import { DirectoryPath } from '../types/file/directoryPath.js';
55
import { FilePath } from '../types/file/filePath.js';
66

@@ -39,24 +39,29 @@ export class ZipService {
3939
public async unArchive(sourceFile: FilePath, destinationDirectory: DirectoryPath): Promise<void> {
4040
const MAX_FILES = 100_000;
4141
const MAX_SIZE = 1_000_000_000; // 1 GB
42-
let fileCount = 0;
43-
let totalSize = 0;
4442

45-
await extract(sourceFile.toString(), {
46-
dir: destinationDirectory.toString(),
47-
onEntry: function (entry) {
48-
fileCount++;
49-
if (fileCount > MAX_FILES) {
50-
throw new Error('Reached max. file count');
51-
}
52-
// The uncompressedSize comes from the zip headers, so it might not be trustworthy.
53-
// Alternatively, calculate the size from the readStream.
54-
let entrySize = entry.uncompressedSize;
55-
totalSize += entrySize;
56-
if (totalSize > MAX_SIZE) {
57-
throw new Error('Reached max. size');
58-
}
43+
// adm-zip extracts synchronously, with no per-entry read streams. This
44+
// avoids a hang on Node 22+ where yauzl/fd-slicer (used by extract-zip)
45+
// builds STORED-entry read streams that deliver every byte but never emit
46+
// `end`, leaving the extraction promise pending forever — even though all
47+
// files have already been written to disk — which crashes the CLI with
48+
// "unsettled top-level await" / exit code 13.
49+
const zip = new AdmZip(sourceFile.toString());
50+
const entries = zip.getEntries();
51+
52+
if (entries.length > MAX_FILES) {
53+
throw new Error('Reached max. file count');
54+
}
55+
// header.size is the uncompressed size declared in the zip headers, so it
56+
// might not be trustworthy — kept as a cheap guard against zip bombs.
57+
let totalSize = 0;
58+
for (const entry of entries) {
59+
totalSize += entry.header.size;
60+
if (totalSize > MAX_SIZE) {
61+
throw new Error('Reached max. size');
5962
}
60-
});
63+
}
64+
65+
zip.extractAllTo(destinationDirectory.toString(), /* overwrite */ true);
6166
}
6267
}

0 commit comments

Comments
 (0)