diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.test.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.test.ts
index 00f013c95..db2538d59 100644
--- a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.test.ts
+++ b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.test.ts
@@ -46,6 +46,8 @@ let lastArchive: EventEmitter & {
finalize: vi.Mock;
};
+const processedEntries: string[] = [];
+
vi.mock('archiver', () => ({
ZipArchive: vi.fn(function () {
let output: EventEmitter | null = null;
@@ -54,7 +56,15 @@ vi.mock('archiver', () => ({
pipe: vi.Mock;
finalize: vi.Mock;
};
- emitter.append = vi.fn();
+ // The real archiver emits `entry` once it has consumed and written an appended source.
+ emitter.append = vi.fn((source: unknown, data: { name: string }) => {
+ if (source instanceof Readable) source.resume();
+ setImmediate(() => {
+ processedEntries.push(data.name);
+ emitter.emit('entry', data);
+ });
+ return emitter;
+ });
emitter.pipe = vi.fn((out: EventEmitter) => {
output = out;
});
@@ -76,10 +86,11 @@ const streamFrom = (value: string) => Readable.from([Buffer.from(value)]);
describe('cbz-zip-patcher', () => {
beforeEach(() => {
vi.clearAllMocks();
+ processedEntries.length = 0;
mockRandomUuid.mockReturnValue('uuid-1234');
mockRename.mockResolvedValue(undefined);
mockUnlink.mockResolvedValue(undefined);
- mockCreateWriteStream.mockImplementation(() => new EventEmitter() as never);
+ mockCreateWriteStream.mockImplementation(() => new PassThrough() as never);
});
it('readComicInfoFromZip finds comicinfo case-insensitively in root or nested paths', async () => {
@@ -119,6 +130,25 @@ describe('cbz-zip-patcher', () => {
expect(mockRename).toHaveBeenCalledWith('/books/.cbx-write-uuid-1234', '/books/a.cbz');
});
+ it('opens each page stream only after the previous page was written', async () => {
+ const openedAfter: string[][] = [];
+ const pages = ['pages/001.jpg', 'pages/002.jpg', 'pages/003.jpg'].map((path) => ({
+ path,
+ stream: vi.fn(() => {
+ openedAfter.push([...processedEntries]);
+ return streamFrom(path);
+ }),
+ }));
+
+ mockOpenFile.mockResolvedValue({ files: pages } as never);
+
+ await writeComicInfoToZip('/books/a.cbz', '');
+
+ expect(openedAfter).toEqual([[], ['pages/001.jpg'], ['pages/001.jpg', 'pages/002.jpg']]);
+ expect(processedEntries).toEqual(['pages/001.jpg', 'pages/002.jpg', 'pages/003.jpg', 'ComicInfo.xml']);
+ for (const page of pages) expect(page.stream).toHaveBeenCalledTimes(1);
+ });
+
it('rejects when a source entry stream errors while patching', async () => {
const sourceError = Object.assign(new Error('ENOENT: no such file or directory, open /books/a.cbz'), { code: 'ENOENT' });
const imageEntry = {
diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts
index ebe43fa59..04ea02123 100644
--- a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts
+++ b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.ts
@@ -1,10 +1,8 @@
-import { createWriteStream } from 'fs';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
-import type { Readable } from 'stream';
import * as unzipper from 'unzipper';
-import { ZipArchive, type Archiver } from 'archiver';
import { replaceFileAtomically } from '../shared/atomic-file-replace';
+import { writeZipArchive, type ZipRewriteEntry } from '../shared/zip-rewrite';
function isComicInfoEntry(entryPath: string): boolean {
const normalized = entryPath.replace(/\\/g, '/').toLowerCase();
@@ -24,29 +22,16 @@ export async function writeComicInfoToZip(filePath: string, xmlContent: string):
const xmlEntryPath = existing?.path ?? 'ComicInfo.xml';
const tmpPath = join(dirname(filePath), `.cbx-write-${randomUUID()}`);
- const archive = new ZipArchive({ zlib: { level: 6 } });
- const output = createWriteStream(tmpPath);
-
- await new Promise((resolve, reject) => {
- output.on('close', resolve);
- output.on('error', reject);
- archive.on('error', reject);
- archive.pipe(output);
-
- for (const entry of zip.files) {
- if (isComicInfoEntry(entry.path)) continue;
- appendEntryStream(archive, entry, reject);
- }
-
- archive.append(Buffer.from(xmlContent, 'utf-8'), { name: xmlEntryPath });
- void archive.finalize();
- });
+ await writeZipArchive(tmpPath, rewriteEntries(zip.files, xmlEntryPath, xmlContent));
await replaceFileAtomically(tmpPath, filePath);
}
-function appendEntryStream(archive: Archiver, entry: { path: string; stream: () => Readable }, reject: (error: Error) => void): void {
- const source = entry.stream();
- source.once('error', reject);
- archive.append(source, { name: entry.path });
+function* rewriteEntries(files: readonly unzipper.File[], xmlEntryPath: string, xmlContent: string): Generator {
+ for (const entry of files) {
+ if (isComicInfoEntry(entry.path)) continue;
+ yield { name: entry.path, source: () => entry.stream() };
+ }
+
+ yield { name: xmlEntryPath, source: Buffer.from(xmlContent, 'utf-8') };
}
diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts
new file mode 100644
index 000000000..b11d77dfe
--- /dev/null
+++ b/server/src/modules/file-write/formats/cbx/cbz-zip-roundtrip.test.ts
@@ -0,0 +1,88 @@
+import { ZipArchive } from 'archiver';
+import { createWriteStream } from 'fs';
+import { mkdtemp, readdir, rm } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import * as unzipper from 'unzipper';
+
+import { readComicInfoFromZip, writeComicInfoToZip } from './cbz-zip-patcher';
+
+let testRoot: string;
+
+beforeEach(async () => {
+ testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-cbz-roundtrip-'));
+});
+
+afterEach(async () => {
+ await rm(testRoot, { recursive: true, force: true });
+});
+
+function pageBytes(index: number): Buffer {
+ const bytes = Buffer.allocUnsafe(512);
+ for (let offset = 0; offset < bytes.length; offset++) bytes[offset] = (index * 31 + offset) % 256;
+ return bytes;
+}
+
+async function writeArchive(filePath: string, entries: Array<{ path: string; content: Buffer }>): Promise {
+ const output = createWriteStream(filePath);
+ const archive = new ZipArchive({ zlib: { level: 6 } });
+
+ await new Promise((resolve, reject) => {
+ output.on('close', resolve);
+ output.on('error', reject);
+ archive.on('error', reject);
+ archive.pipe(output);
+ for (const entry of entries) archive.append(entry.content, { name: entry.path });
+ void archive.finalize();
+ });
+}
+
+async function readArchive(filePath: string): Promise> {
+ const zip = await unzipper.Open.file(filePath);
+ return Promise.all(zip.files.map(async (entry) => ({ path: entry.path, content: await entry.buffer() })));
+}
+
+function comicPages(count: number): Array<{ path: string; content: Buffer }> {
+ return Array.from({ length: count }, (_unused, index) => ({
+ path: `pages/${String(index + 1).padStart(3, '0')}.jpg`,
+ content: pageBytes(index),
+ }));
+}
+
+describe('cbz zip round trip', () => {
+ it('preserves every page byte-for-byte and appends comic info when absent', async () => {
+ const target = join(testRoot, 'volume.cbz');
+ const pages = comicPages(120);
+ await writeArchive(target, pages);
+
+ await writeComicInfoToZip(target, 'Saga');
+
+ const written = await readArchive(target);
+ expect(written.map((entry) => entry.path)).toEqual([...pages.map((page) => page.path), 'ComicInfo.xml']);
+ for (const [index, page] of pages.entries()) {
+ expect(written[index]!.content.equals(page.content)).toBe(true);
+ }
+ await expect(readComicInfoFromZip(target)).resolves.toBe('Saga');
+ });
+
+ it('replaces existing comic info in place without duplicating it', async () => {
+ const target = join(testRoot, 'volume.cbz');
+ const pages = comicPages(5);
+ await writeArchive(target, [{ path: 'metadata/ComicInfo.XML', content: Buffer.from('Old') }, ...pages]);
+
+ await writeComicInfoToZip(target, 'New');
+
+ const written = await readArchive(target);
+ expect(written.map((entry) => entry.path)).toEqual([...pages.map((page) => page.path), 'metadata/ComicInfo.XML']);
+ await expect(readComicInfoFromZip(target)).resolves.toBe('New');
+ });
+
+ it('leaves no temporary archive behind', async () => {
+ const target = join(testRoot, 'volume.cbz');
+ await writeArchive(target, comicPages(3));
+
+ await writeComicInfoToZip(target, '');
+
+ await expect(readdir(testRoot)).resolves.toEqual(['volume.cbz']);
+ });
+});
diff --git a/server/src/modules/file-write/formats/epub/epub-zip-patcher.test.ts b/server/src/modules/file-write/formats/epub/epub-zip-patcher.test.ts
index ea624ade5..e53362331 100644
--- a/server/src/modules/file-write/formats/epub/epub-zip-patcher.test.ts
+++ b/server/src/modules/file-write/formats/epub/epub-zip-patcher.test.ts
@@ -37,6 +37,8 @@ let lastArchive: EventEmitter & {
finalize: vi.Mock;
};
+const processedEntries: string[] = [];
+
vi.mock('archiver', () => ({
ZipArchive: vi.fn(function () {
let output: EventEmitter | null = null;
@@ -45,7 +47,15 @@ vi.mock('archiver', () => ({
pipe: vi.Mock;
finalize: vi.Mock;
};
- emitter.append = vi.fn();
+ // The real archiver emits `entry` once it has consumed and written an appended source.
+ emitter.append = vi.fn((source: unknown, data: { name: string }) => {
+ if (source instanceof Readable) source.resume();
+ setImmediate(() => {
+ processedEntries.push(data.name);
+ emitter.emit('entry', data);
+ });
+ return emitter;
+ });
emitter.pipe = vi.fn((out: EventEmitter) => {
output = out;
});
@@ -66,7 +76,8 @@ const streamFrom = (value: string) => Readable.from([Buffer.from(value)]);
describe('epub-zip-patcher', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockCreateWriteStream.mockImplementation(() => new EventEmitter() as never);
+ processedEntries.length = 0;
+ mockCreateWriteStream.mockImplementation(() => new PassThrough() as never);
mockRename.mockResolvedValue(undefined);
mockUnlink.mockResolvedValue(undefined);
});
@@ -116,6 +127,25 @@ describe('epub-zip-patcher', () => {
expect(mockRename).toHaveBeenCalledWith('/book.epub.tmp', '/book.epub');
});
+ it('opens each carried-over entry stream only after the previous entry was written', async () => {
+ const openedAfter: string[][] = [];
+ const carried = ['OPS/ch1.xhtml', 'OPS/ch2.xhtml', 'OPS/ch3.xhtml'].map((path) => ({
+ path,
+ stream: vi.fn(() => {
+ openedAfter.push([...processedEntries]);
+ return streamFrom(path);
+ }),
+ }));
+
+ mockOpenFile.mockResolvedValue({ files: carried } as never);
+
+ await patch('/book.epub', new Map());
+
+ expect(openedAfter).toEqual([['mimetype'], ['mimetype', 'OPS/ch1.xhtml'], ['mimetype', 'OPS/ch1.xhtml', 'OPS/ch2.xhtml']]);
+ expect(processedEntries).toEqual(['mimetype', 'OPS/ch1.xhtml', 'OPS/ch2.xhtml', 'OPS/ch3.xhtml']);
+ for (const entry of carried) expect(entry.stream).toHaveBeenCalledTimes(1);
+ });
+
it('rejects when a source entry stream errors while patching', async () => {
const sourceError = Object.assign(new Error('ENOENT: no such file or directory, open /book.epub'), { code: 'ENOENT' });
const missingEntry = {
diff --git a/server/src/modules/file-write/formats/epub/epub-zip-patcher.ts b/server/src/modules/file-write/formats/epub/epub-zip-patcher.ts
index 410d0ccc2..97c747de5 100644
--- a/server/src/modules/file-write/formats/epub/epub-zip-patcher.ts
+++ b/server/src/modules/file-write/formats/epub/epub-zip-patcher.ts
@@ -1,8 +1,8 @@
-import { createWriteStream } from 'fs';
-import type { Readable } from 'stream';
import * as unzipper from 'unzipper';
-import { ZipArchive, type Archiver } from 'archiver';
import { replaceFileAtomically } from '../shared/atomic-file-replace';
+import { writeZipArchive, type ZipRewriteEntry } from '../shared/zip-rewrite';
+
+const MIMETYPE_ENTRY_PATH = 'mimetype';
export async function readEntry(filePath: string, entryPath: string): Promise {
const zip = await unzipper.Open.file(filePath);
@@ -19,41 +19,24 @@ export async function listEntryPaths(filePath: string): Promise {
export async function patch(filePath: string, patches: Map): Promise {
const tmpPath = filePath + '.tmp';
const zip = await unzipper.Open.file(filePath);
- const archive = new ZipArchive({ zlib: { level: 6 } });
- const output = createWriteStream(tmpPath);
- await new Promise((resolve, reject) => {
- output.on('close', resolve);
- output.on('error', reject);
- archive.on('error', reject);
- archive.pipe(output);
-
- archive.append(Buffer.from('application/epub+zip'), { name: 'mimetype', store: true });
-
- for (const entry of zip.files) {
- if (entry.path === 'mimetype') continue;
- const patched = patches.get(entry.path);
- if (patched) {
- archive.append(patched, { name: entry.path });
- } else {
- appendEntryStream(archive, entry, reject);
- }
- }
-
- for (const [path, content] of patches) {
- if (!zip.files.some((e) => e.path === path)) {
- archive.append(content, { name: path });
- }
- }
-
- void archive.finalize();
- });
+ await writeZipArchive(tmpPath, rewriteEntries(zip.files, patches));
await replaceFileAtomically(tmpPath, filePath);
}
-function appendEntryStream(archive: Archiver, entry: { path: string; stream: () => Readable }, reject: (error: Error) => void): void {
- const source = entry.stream();
- source.once('error', reject);
- archive.append(source, { name: entry.path });
+function* rewriteEntries(files: readonly unzipper.File[], patches: Map): Generator {
+ yield { name: MIMETYPE_ENTRY_PATH, source: Buffer.from('application/epub+zip'), store: true };
+
+ for (const entry of files) {
+ if (entry.path === MIMETYPE_ENTRY_PATH) continue;
+ const patched = patches.get(entry.path);
+ yield { name: entry.path, source: patched ?? (() => entry.stream()) };
+ }
+
+ for (const [path, content] of patches) {
+ if (!files.some((entry) => entry.path === path)) {
+ yield { name: path, source: content };
+ }
+ }
}
diff --git a/server/src/modules/file-write/formats/shared/zip-rewrite.test.ts b/server/src/modules/file-write/formats/shared/zip-rewrite.test.ts
new file mode 100644
index 000000000..e994fc181
--- /dev/null
+++ b/server/src/modules/file-write/formats/shared/zip-rewrite.test.ts
@@ -0,0 +1,219 @@
+import { mkdtemp, rm } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { PassThrough, Readable } from 'stream';
+import * as unzipper from 'unzipper';
+
+import { writeZipArchive, type ZipRewriteEntry } from './zip-rewrite';
+
+const STORED_COMPRESSION_METHOD = 0;
+
+let testRoot: string;
+
+beforeEach(async () => {
+ testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-zip-rewrite-'));
+});
+
+afterEach(async () => {
+ await rm(testRoot, { recursive: true, force: true });
+});
+
+function chunkedStream(value: string, chunks = 4): Readable {
+ const size = Math.max(1, Math.ceil(value.length / chunks));
+ const parts: Buffer[] = [];
+ for (let offset = 0; offset < value.length; offset += size) parts.push(Buffer.from(value.slice(offset, offset + size)));
+ return Readable.from(parts);
+}
+
+/** Tracks how many entry sources are open at once, which is what the eager-append regression cost. */
+function trackedSources(count: number): { entries: ZipRewriteEntry[]; openOrder: string[]; maxConcurrentOpen: () => number } {
+ const openOrder: string[] = [];
+ let open = 0;
+ let maxOpen = 0;
+
+ const entries = Array.from({ length: count }, (_unused, index): ZipRewriteEntry => {
+ const name = `pages/${String(index).padStart(4, '0')}.txt`;
+ return {
+ name,
+ source: () => {
+ openOrder.push(name);
+ open += 1;
+ maxOpen = Math.max(maxOpen, open);
+ const stream = chunkedStream(`contents of ${name}`);
+ stream.once('end', () => {
+ open -= 1;
+ });
+ return stream;
+ },
+ };
+ });
+
+ return { entries, openOrder, maxConcurrentOpen: () => maxOpen };
+}
+
+async function readArchive(filePath: string): Promise> {
+ const zip = await unzipper.Open.file(filePath);
+ return Promise.all(
+ zip.files.map(async (entry) => ({
+ path: entry.path,
+ compressionMethod: entry.compressionMethod,
+ content: (await entry.buffer()).toString('utf-8'),
+ })),
+ );
+}
+
+describe('writeZipArchive', () => {
+ it('writes buffer and stream sources in the order they are yielded', async () => {
+ const target = join(testRoot, 'out.zip');
+
+ await writeZipArchive(target, [
+ { name: 'mimetype', source: Buffer.from('application/epub+zip'), store: true },
+ { name: 'META-INF/container.xml', source: () => chunkedStream('') },
+ { name: 'OPS/content.opf', source: Buffer.from('') },
+ ]);
+
+ const written = await readArchive(target);
+ expect(written.map((entry) => entry.path)).toEqual(['mimetype', 'META-INF/container.xml', 'OPS/content.opf']);
+ expect(written.map((entry) => entry.content)).toEqual(['application/epub+zip', '', '']);
+ });
+
+ it('stores entries flagged with store and compresses the rest', async () => {
+ const target = join(testRoot, 'out.zip');
+ const compressible = 'a'.repeat(4096);
+
+ await writeZipArchive(target, [
+ { name: 'mimetype', source: Buffer.from('application/epub+zip'), store: true },
+ { name: 'OPS/ch1.xhtml', source: Buffer.from(compressible) },
+ ]);
+
+ const written = await readArchive(target);
+ expect(written[0]!.compressionMethod).toBe(STORED_COMPRESSION_METHOD);
+ expect(written[1]!.compressionMethod).not.toBe(STORED_COMPRESSION_METHOD);
+ expect(written[1]!.content).toBe(compressible);
+ });
+
+ it('opens at most one entry source at a time', async () => {
+ const target = join(testRoot, 'out.zip');
+ const { entries, openOrder, maxConcurrentOpen } = trackedSources(150);
+
+ await writeZipArchive(target, entries);
+
+ expect(maxConcurrentOpen()).toBe(1);
+ expect(openOrder).toEqual(entries.map((entry) => entry.name));
+ const written = await readArchive(target);
+ expect(written).toHaveLength(150);
+ expect(written[149]!.content).toBe('contents of pages/0149.txt');
+ });
+
+ it('does not open a source until the preceding sources have been consumed', async () => {
+ const target = join(testRoot, 'out.zip');
+ const drained: string[] = [];
+ const seenWhenOpened = new Map();
+ const lazySource = (name: string) => () => {
+ seenWhenOpened.set(name, [...drained]);
+ const stream = chunkedStream(`contents of ${name}`);
+ stream.once('end', () => {
+ drained.push(name);
+ });
+ return stream;
+ };
+
+ await writeZipArchive(target, [
+ { name: 'first', source: lazySource('first') },
+ { name: 'second', source: lazySource('second') },
+ { name: 'third', source: lazySource('third') },
+ ]);
+
+ expect(seenWhenOpened.get('first')).toEqual([]);
+ expect(seenWhenOpened.get('second')).toEqual(['first']);
+ expect(seenWhenOpened.get('third')).toEqual(['first', 'second']);
+ });
+
+ it('accepts an iterable that is consumed lazily', async () => {
+ const target = join(testRoot, 'out.zip');
+ let yielded = 0;
+ function* entries(): Generator {
+ for (const name of ['a.txt', 'b.txt', 'c.txt']) {
+ yielded += 1;
+ yield { name, source: Buffer.from(name) };
+ }
+ }
+
+ await writeZipArchive(target, entries());
+
+ expect(yielded).toBe(3);
+ expect((await readArchive(target)).map((entry) => entry.path)).toEqual(['a.txt', 'b.txt', 'c.txt']);
+ });
+
+ it('writes zero-length entries without stalling the append loop', async () => {
+ const target = join(testRoot, 'out.zip');
+
+ await writeZipArchive(target, [
+ { name: 'empty.txt', source: Buffer.alloc(0) },
+ { name: 'after-empty.txt', source: Buffer.from('still written') },
+ ]);
+
+ const written = await readArchive(target);
+ expect(written.map((entry) => entry.path)).toEqual(['empty.txt', 'after-empty.txt']);
+ expect(written.map((entry) => entry.content)).toEqual(['', 'still written']);
+ });
+
+ it('writes an empty archive when there are no entries', async () => {
+ const target = join(testRoot, 'empty.zip');
+
+ await writeZipArchive(target, []);
+
+ await expect(readArchive(target)).resolves.toEqual([]);
+ });
+
+ it('rejects with the source error and stops opening further sources', async () => {
+ const target = join(testRoot, 'out.zip');
+ const sourceError = Object.assign(new Error('ENOENT: no such file or directory, open /book.cbz'), { code: 'ENOENT' });
+ const laterSource = vi.fn(() => chunkedStream('never reached'));
+
+ const failing = writeZipArchive(target, [
+ {
+ name: 'pages/001.jpg',
+ source: () => {
+ const source = new PassThrough();
+ process.nextTick(() => source.emit('error', sourceError));
+ return source;
+ },
+ },
+ { name: 'pages/002.jpg', source: laterSource },
+ ]);
+
+ await expect(failing).rejects.toThrow('ENOENT');
+ expect(laterSource).not.toHaveBeenCalled();
+ });
+
+ it('rejects when the archive rejects an entry instead of hanging', async () => {
+ const target = join(testRoot, 'out.zip');
+
+ await expect(writeZipArchive(target, [{ name: '', source: Buffer.from('x') }])).rejects.toThrow(/ENTRYNAMEREQUIRED|entry name/i);
+ });
+
+ it('rejects when the output file cannot be opened', async () => {
+ const target = join(testRoot, 'missing-directory', 'out.zip');
+
+ await expect(writeZipArchive(target, [{ name: 'a.txt', source: Buffer.from('a') }])).rejects.toThrow(/ENOENT/);
+ });
+
+ it('releases the output stream when a source fails', async () => {
+ const target = join(testRoot, 'out.zip');
+
+ const failing = writeZipArchive(target, [
+ {
+ name: 'a.txt',
+ source: () => {
+ const source = new PassThrough();
+ process.nextTick(() => source.emit('error', new Error('read failed')));
+ return source;
+ },
+ },
+ ]);
+
+ await expect(failing).rejects.toThrow('read failed');
+ await expect(rm(target, { force: true })).resolves.toBeUndefined();
+ });
+});
diff --git a/server/src/modules/file-write/formats/shared/zip-rewrite.ts b/server/src/modules/file-write/formats/shared/zip-rewrite.ts
new file mode 100644
index 000000000..6f67f81f9
--- /dev/null
+++ b/server/src/modules/file-write/formats/shared/zip-rewrite.ts
@@ -0,0 +1,55 @@
+import { createWriteStream } from 'fs';
+import type { Readable } from 'stream';
+import { ZipArchive, type Archiver } from 'archiver';
+
+const ZLIB_COMPRESSION_LEVEL = 6;
+
+export interface ZipRewriteEntry {
+ name: string;
+ source: Buffer | (() => Readable);
+ store?: boolean;
+}
+
+/**
+ * Archiver pipes an appended stream into its queue immediately, so appending every entry of a large
+ * archive up front holds one descriptor and one decompression buffer per entry until the rewrite
+ * finishes. Entries are appended one at a time and their sources opened only once archiver is ready
+ * for them, which keeps descriptors and memory flat regardless of entry count.
+ */
+export async function writeZipArchive(tmpPath: string, entries: Iterable): Promise {
+ const archive = new ZipArchive({ zlib: { level: ZLIB_COMPRESSION_LEVEL } });
+ const output = createWriteStream(tmpPath);
+
+ let fail!: (error: Error) => void;
+ const failure = new Promise((_resolve, reject) => {
+ fail = reject;
+ });
+ // The losing side of every race below stays rejected; keep it handled so it can never surface as
+ // an unhandled rejection.
+ failure.catch(() => {});
+
+ archive.on('error', fail);
+ output.on('error', fail);
+ const closed = new Promise((resolve) => output.once('close', resolve));
+ archive.pipe(output);
+
+ try {
+ for (const entry of entries) {
+ await Promise.race([appendEntry(archive, entry, fail), failure]);
+ }
+ void archive.finalize();
+ await Promise.race([closed, failure]);
+ } catch (error) {
+ output.destroy();
+ throw error;
+ }
+}
+
+function appendEntry(archive: Archiver, entry: ZipRewriteEntry, fail: (error: Error) => void): Promise {
+ return new Promise((resolve) => {
+ const source = typeof entry.source === 'function' ? entry.source() : entry.source;
+ if (!Buffer.isBuffer(source)) source.once('error', fail);
+ archive.once('entry', () => resolve());
+ archive.append(source, entry.store ? { name: entry.name, store: true } : { name: entry.name });
+ });
+}