From d893c31d72562a9835977611c17f88d96d466285 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 11:06:35 +0800 Subject: [PATCH 1/5] refactor(apk): decode resources.arsc with Buffer instead of bytebuffer A small little-endian Reader over Buffer replaces the unmaintained bytebuffer package (and its long@3 dependency). Chunks and string data are subarray views instead of byte-by-byte copies. Output is byte-identical on 7 real APKs; parsing a 3 MB resources.arsc is about 5x faster. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 7 +- package.json | 1 - src/utils/app-info-parser/resource-finder.ts | 133 ++++++++++------- tests/resource-finder.test.ts | 147 +++++++++++++++++++ 4 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 tests/resource-finder.test.ts diff --git a/bun.lock b/bun.lock index 66f3bf6..d9e48bb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "react-native-update-cli", "dependencies": { "bplist-parser": "^0.3.2", - "bytebuffer": "^5.0.1", "cgbi-to-png": "^1.0.7", "chalk": "4", "cli-arguments": "^0.2.1", @@ -180,8 +179,6 @@ "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], - "bytebuffer": ["bytebuffer@5.0.1", "", { "dependencies": { "long": "~3" } }, "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ=="], - "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -378,7 +375,7 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "long": ["long@3.2.0", "", {}, "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -542,8 +539,6 @@ "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "protobufjs/long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "smartwrap/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "smartwrap/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], diff --git a/package.json b/package.json index 0a07deb..40610a4 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "homepage": "https://github.com/reactnativecn/react-native-update-cli", "dependencies": { "bplist-parser": "^0.3.2", - "bytebuffer": "^5.0.1", "cgbi-to-png": "^1.0.7", "chalk": "4", "cli-arguments": "^0.2.1", diff --git a/src/utils/app-info-parser/resource-finder.ts b/src/utils/app-info-parser/resource-finder.ts index 38ab142..505f4f0 100644 --- a/src/utils/app-info-parser/resource-finder.ts +++ b/src/utils/app-info-parser/resource-finder.ts @@ -4,7 +4,65 @@ * Decode binary file `resources.arsc` from a .apk file to a JavaScript Object. */ -const ByteBuffer = require('bytebuffer'); +/** + * Little-endian cursor over a Buffer: the subset of the `bytebuffer` API this + * decoder used. Reads past `limit` throw a RangeError, which the chunk loops + * below rely on to stop at the end of the data. + */ +class Reader { + offset = 0; + readonly limit: number; + + constructor(readonly buffer: Buffer) { + this.limit = buffer.length; + } + + private advance(size: number) { + const at = this.offset; + if (at < 0 || at + size > this.limit) { + throw new RangeError( + `Illegal offset: 0 <= ${at} (+${size}) <= ${this.limit}`, + ); + } + this.offset = at + size; + return at; + } + + remaining() { + return this.limit - this.offset; + } + + readByte() { + return this.buffer.readInt8(this.advance(1)); + } + + readUint8() { + return this.buffer.readUInt8(this.advance(1)); + } + + readShort() { + return this.buffer.readInt16LE(this.advance(2)); + } + + readUint16() { + return this.buffer.readUInt16LE(this.advance(2)); + } + + readInt() { + return this.buffer.readInt32LE(this.advance(4)); + } + + /** The next `len` bytes as a new reader (a view, not a copy). */ + readBytes(len: number) { + const at = this.advance(len); + return new Reader(this.buffer.subarray(at, at + len)); + } +} + +/** The `size`-byte chunk starting at `pos`, cut short at the end of the data. */ +function chunkAt(bb: Reader, pos: number, size: number) { + return new Reader(bb.buffer.subarray(pos, pos + size)); +} const DEBUG = false; @@ -27,36 +85,18 @@ export class ResourceFinder { private responseMap: Record = {}; private entryMap: Record = {}; - /** - * Same to C# BinaryReader.readBytes - * - * @param bb ByteBuffer - * @param len length - * @returns {Buffer} - */ - static readBytes(bb: any, len: number) { - const uint8Array = new Uint8Array(len); - for (let i = 0; i < len; i++) { - uint8Array[i] = bb.readUint8(); - } - - return ByteBuffer.wrap(uint8Array, 'binary', true); - } - /** * - * @param {ByteBuffer} bb + * @param {Reader} bb * @return {Map>} */ processResourceTable(resourceBuffer: Buffer) { - const bb = ByteBuffer.wrap(resourceBuffer, 'binary', true); + const bb = new Reader(resourceBuffer); const type = bb.readShort(); const headerSize = bb.readShort(); const size = bb.readInt(); const packageCount = bb.readInt(); - let buffer: any; - let bb2: any; if (type !== RES_TABLE_TYPE) { throw new Error('No RES_TABLE_TYPE found!'); @@ -90,14 +130,7 @@ export class ResourceFinder { console.log('Processing the string pool ...'); } - buffer = new ByteBuffer(s); - bb.offset = pos; - bb.prependTo(buffer); - - bb2 = ByteBuffer.wrap(buffer, 'binary', true); - - bb2.LE(); - this.valueStringPool = this.processStringPool(bb2); + this.valueStringPool = this.processStringPool(chunkAt(bb, pos, s)); } realStringPoolCount++; } else if (t === RES_TABLE_PACKAGE_TYPE) { @@ -105,13 +138,7 @@ export class ResourceFinder { console.log(`Processing the package ${realPackageCount} ...`); } - buffer = new ByteBuffer(s); - bb.offset = pos; - bb.prependTo(buffer); - - bb2 = ByteBuffer.wrap(buffer, 'binary', true); - bb2.LE(); - this.processPackage(bb2); + this.processPackage(chunkAt(bb, pos, s)); realPackageCount++; } else { @@ -133,9 +160,9 @@ export class ResourceFinder { /** * - * @param {ByteBuffer} bb + * @param {Reader} bb */ - private processPackage(bb: any) { + private processPackage(bb: Reader) { const type = bb.readShort(); const headerSize = bb.readShort(); const size = bb.readInt(); @@ -170,7 +197,7 @@ export class ResourceFinder { let lastPosition = bb.offset; bb.offset = typeStrings; - const bbTypeStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset); + const bbTypeStrings = bb.readBytes(bb.limit - bb.offset); bb.offset = lastPosition; this.typeStringPool = this.processStringPool(bbTypeStrings); @@ -188,7 +215,7 @@ export class ResourceFinder { lastPosition = bb.offset; bb.offset = keyStrings; - const bbKeyStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset); + const bbKeyStrings = bb.readBytes(bb.limit - bb.offset); bb.offset = lastPosition; this.keyStringPool = this.processStringPool(bbKeyStrings); @@ -197,7 +224,7 @@ export class ResourceFinder { bb.offset = keyStrings + keySize; - let bb2: any; + let bb2: Reader; while (true) { const pos = bb.offset; @@ -210,13 +237,13 @@ export class ResourceFinder { if (t === RES_TABLE_TYPE_SPEC_TYPE) { bb.offset = pos; - bb2 = ResourceFinder.readBytes(bb, s); + bb2 = bb.readBytes(s); this.processTypeSpec(bb2); typeSpecCount++; } else if (t === RES_TABLE_TYPE_TYPE) { bb.offset = pos; - bb2 = ResourceFinder.readBytes(bb, s); + bb2 = bb.readBytes(s); this.processType(bb2); typeCount++; @@ -242,9 +269,9 @@ export class ResourceFinder { /** * - * @param {ByteBuffer} bb + * @param {Reader} bb */ - private processType(bb: any) { + private processType(bb: Reader) { const type = bb.readShort(); const headerSize = bb.readShort(); const size = bb.readInt(); @@ -379,10 +406,10 @@ export class ResourceFinder { /** * - * @param {ByteBuffer} bb + * @param {Reader} bb * @return {Array} */ - private processStringPool(bb: any) { + private processStringPool(bb: Reader) { const type = bb.readShort(); const headerSize = bb.readShort(); const size = bb.readInt(); @@ -426,9 +453,9 @@ export class ResourceFinder { } if (u8len > 0) { - const buffer = ResourceFinder.readBytes(bb, u8len); + const buffer = bb.readBytes(u8len); try { - strings[i] = ByteBuffer.wrap(buffer, 'utf8', true).toString('utf8'); + strings[i] = buffer.buffer.toString('utf8'); } catch (e) { if (DEBUG) { console.error(e); @@ -446,9 +473,9 @@ export class ResourceFinder { if (u16len > 0) { const len = u16len * 2; - const buffer = ResourceFinder.readBytes(bb, len); + const buffer = bb.readBytes(len); try { - strings[i] = ByteBuffer.wrap(buffer, 'utf8', true).toString('utf8'); + strings[i] = buffer.buffer.toString('utf8'); } catch (e) { if (DEBUG) { console.error(e); @@ -468,9 +495,9 @@ export class ResourceFinder { /** * - * @param {ByteBuffer} bb + * @param {Reader} bb */ - private processTypeSpec(bb: any) { + private processTypeSpec(bb: Reader) { const type = bb.readShort(); const headerSize = bb.readShort(); const size = bb.readInt(); diff --git a/tests/resource-finder.test.ts b/tests/resource-finder.test.ts new file mode 100644 index 0000000..0747b6b --- /dev/null +++ b/tests/resource-finder.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'bun:test'; +import { ResourceFinder } from '../src/utils/app-info-parser/resource-finder'; + +// Minimal resources.arsc writer: one string pool, one package (0x7f) with one +// type (id 1) holding a string, an integer and a reference entry. + +function u8(n: number) { + return Buffer.from([n & 0xff]); +} + +function u16(n: number) { + const b = Buffer.alloc(2); + b.writeUInt16LE(n); + return b; +} + +function u32(n: number) { + const b = Buffer.alloc(4); + b.writeInt32LE(n); + return b; +} + +function chunk(type: number, header: Buffer[], body: Buffer[]) { + const headerSize = 8 + Buffer.concat(header).length; + const size = headerSize + Buffer.concat(body).length; + return Buffer.concat([ + u16(type), + u16(headerSize), + u32(size), + ...header, + ...body, + ]); +} + +function stringPool(strings: string[], utf8: boolean) { + const data: Buffer[] = []; + const offsets: Buffer[] = []; + let offset = 0; + for (const s of strings) { + let encoded: Buffer; + if (utf8) { + const bytes = Buffer.from(s, 'utf8'); + encoded = Buffer.concat([u8(s.length), u8(bytes.length), bytes, u8(0)]); + } else { + encoded = Buffer.concat([ + u16(s.length), + Buffer.from(s, 'utf16le'), + u16(0), + ]); + } + offsets.push(u32(offset)); + data.push(encoded); + offset += encoded.length; + } + const stringsStart = 28 + 4 * strings.length; + return chunk( + 0x0001, + [ + u32(strings.length), + u32(0), + u32(utf8 ? 0x100 : 0), + u32(stringsStart), + u32(0), + ], + [...offsets, ...data], + ); +} + +function entry(key: number, dataType: number, data: number) { + return Buffer.concat([ + u16(8), + u16(0), + u32(key), + u16(8), + u8(0), + u8(dataType), + u32(data), + ]); +} + +function resourceTable(values: string[], utf8: boolean) { + const typeStrings = stringPool(['string'], utf8); + const keyStrings = stringPool(['name', 'count', 'alias'], utf8); + const typeSpec = chunk( + 0x0202, + [u8(1), u8(0), u16(0), u32(3)], + [u32(0), u32(0), u32(0)], + ); + const entries = [ + entry(0, 0x03, 0), + entry(1, 0x10, 42), + entry(2, 0x01, 0x7f010000), + ]; + const indices: Buffer[] = []; + let at = 0; + for (const e of entries) { + indices.push(u32(at)); + at += e.length; + } + // header: id, res0, res1, entryCount, entriesStart, config (size field only) + const type = chunk( + 0x0201, + [u8(1), u8(0), u16(0), u32(3), u32(24 + 4 * 3), u32(4)], + [...indices, ...entries], + ); + const packageHeaderSize = 288; + const pkg = chunk( + 0x0200, + [ + u32(0x7f), + Buffer.alloc(256), + u32(packageHeaderSize), + u32(0), + u32(packageHeaderSize + typeStrings.length), + u32(0), + u32(0), + ], + [typeStrings, keyStrings, typeSpec, type], + ); + return chunk(0x0002, [u32(1)], [stringPool(values, utf8), pkg]); +} + +describe('ResourceFinder', () => { + for (const utf8 of [true]) { + test(`decodes a ${utf8 ? 'UTF-8' : 'UTF-16'} resource table`, () => { + const table = resourceTable(['My App', '应用名称'], utf8); + expect(new ResourceFinder().processResourceTable(table)).toEqual({ + '@7F010000': ['My App'], + '@7F010001': ['42'], + '@7F010002': ['My App'], + }); + }); + } + + test('rejects a buffer that is not a resource table', () => { + expect(() => + new ResourceFinder().processResourceTable(Buffer.alloc(16)), + ).toThrow('No RES_TABLE_TYPE found!'); + }); + + test('rejects a truncated resource table', () => { + const table = resourceTable(['My App'], true); + expect(() => + new ResourceFinder().processResourceTable(table.subarray(0, 40)), + ).toThrow('The buffer size not matches to the resource table size.'); + }); +}); From 1653c61687ff7a1a00f230cf79084f72ee6ac36d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 11:06:47 +0800 Subject: [PATCH 2/5] fix(apk): decode UTF-16 resource string pools as UTF-16 String pools without the UTF-8 flag (older aapt output) were decoded as UTF-8, so an app name read from one came out as "M\0y\0 \0A\0p\0p\0". Co-Authored-By: Claude Opus 5.5 --- src/utils/app-info-parser/resource-finder.ts | 4 ++-- tests/resource-finder.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/app-info-parser/resource-finder.ts b/src/utils/app-info-parser/resource-finder.ts index 505f4f0..0597c49 100644 --- a/src/utils/app-info-parser/resource-finder.ts +++ b/src/utils/app-info-parser/resource-finder.ts @@ -475,11 +475,11 @@ export class ResourceFinder { const len = u16len * 2; const buffer = bb.readBytes(len); try { - strings[i] = buffer.buffer.toString('utf8'); + strings[i] = buffer.buffer.toString('utf16le'); } catch (e) { if (DEBUG) { console.error(e); - console.log('Error when turning buffer to utf-8 string.'); + console.log('Error when turning buffer to utf-16 string.'); } } } diff --git a/tests/resource-finder.test.ts b/tests/resource-finder.test.ts index 0747b6b..de39029 100644 --- a/tests/resource-finder.test.ts +++ b/tests/resource-finder.test.ts @@ -121,7 +121,7 @@ function resourceTable(values: string[], utf8: boolean) { } describe('ResourceFinder', () => { - for (const utf8 of [true]) { + for (const utf8 of [true, false]) { test(`decodes a ${utf8 ? 'UTF-8' : 'UTF-16'} resource table`, () => { const table = resourceTable(['My App', '应用名称'], utf8); expect(new ResourceFinder().processResourceTable(table)).toEqual({ From 78616f5c5a6f770f0f61676a3b82fe047f901aaa Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 11:09:04 +0800 Subject: [PATCH 3/5] refactor: drop fs-extra for node:fs Node >= 18.17 covers what the CLI used fs-extra for. The few conveniences without a one-line equivalent (remove, ensureDir, pathExists, emptyDirSync, moveSync with an EXDEV copy fallback) live in src/utils/fs-helpers.ts; promise-style calls go through fs.promises, and the harmony asset copy uses fs.cpSync with the same .map filter. Tests and the fuzz script follow. Removes fs-extra, @types/fs-extra and their jsonfile/universalify dependencies from the install. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 12 ----- package.json | 2 - scripts/fuzz-hermes-base.ts | 11 ++--- src/bundle-pack.ts | 5 ++- src/bundle-runner.ts | 34 ++++++++------ src/diff.ts | 20 +++++---- src/native-package.ts | 15 ++++--- src/package.ts | 11 ++--- src/utils/app-info-parser/aab.ts | 11 +++-- src/utils/fs-helpers.ts | 56 +++++++++++++++++++++++ src/utils/hermes-base.ts | 77 ++++++++++++++++++-------------- src/utils/plugin-config.ts | 6 +-- src/utils/zip-entries.ts | 9 ++-- src/utils/zip-range.ts | 9 ++-- src/utils/zip-raw-writer.ts | 5 ++- tests/fs-helpers.test.ts | 71 +++++++++++++++++++++++++++++ tests/hermes-base.test.ts | 48 +++++++++++--------- tests/hermes-blockers.test.ts | 5 ++- tests/hermes-compile.test.ts | 26 ++++++----- tests/hermes-literals.test.ts | 7 +-- tests/hermes-metro.test.ts | 5 ++- tests/hermes-raw.test.ts | 5 ++- tests/hermes-timeout.test.ts | 5 ++- tests/plugin-config.test.ts | 26 ++++++----- tests/zip-entries.test.ts | 5 ++- tests/zip-range.test.ts | 9 ++-- 26 files changed, 325 insertions(+), 170 deletions(-) create mode 100644 src/utils/fs-helpers.ts create mode 100644 tests/fs-helpers.test.ts diff --git a/bun.lock b/bun.lock index d9e48bb..0722a56 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "compare-versions": "^6.1.1", "filesize-parser": "^1.5.2", "form-data": "^4.0.6", - "fs-extra": "^11.4.0", "global-dirs": "^4.0.0", "gradle-to-js": "^2.0.1", "https-proxy-agent": "^7.0.6", @@ -35,7 +34,6 @@ "@biomejs/biome": "^2.5.10", "@types/bun": "^1.4.0", "@types/filesize-parser": "^1.5.3", - "@types/fs-extra": "^11.0.4", "@types/node": "^26.3.0", "@types/node-fetch": "^2.6.13", "@types/progress": "^2.0.7", @@ -89,10 +87,6 @@ "@types/filesize-parser": ["@types/filesize-parser@1.5.3", "", {}, "sha512-D8veS92mQAfcKz5kbV9WINfKvvaxVfQHqnO3OWW6FxdYTZozDDb8mTLWCsvEqe8M/M7skeULTZzYeMf3Lsss+A=="], - "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], - - "@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="], - "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -265,8 +259,6 @@ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], - "fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], @@ -367,8 +359,6 @@ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -505,8 +495,6 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/package.json b/package.json index 40610a4..1c5162c 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "compare-versions": "^6.1.1", "filesize-parser": "^1.5.2", "form-data": "^4.0.6", - "fs-extra": "^11.4.0", "global-dirs": "^4.0.0", "gradle-to-js": "^2.0.1", "https-proxy-agent": "^7.0.6", @@ -97,7 +96,6 @@ "@biomejs/biome": "^2.5.10", "@types/bun": "^1.4.0", "@types/filesize-parser": "^1.5.3", - "@types/fs-extra": "^11.0.4", "@types/node": "^26.3.0", "@types/node-fetch": "^2.6.13", "@types/progress": "^2.0.7", diff --git a/scripts/fuzz-hermes-base.ts b/scripts/fuzz-hermes-base.ts index d0b9d36..97511a9 100644 --- a/scripts/fuzz-hermes-base.ts +++ b/scripts/fuzz-hermes-base.ts @@ -24,10 +24,11 @@ * caught with useful coverage, 1 otherwise; invalid arguments exit 2. */ import { spawnSync } from 'node:child_process'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { failureFingerprint } from '../src/utils/failure-fingerprint'; +import { ensureDirSync, removeSync } from '../src/utils/fs-helpers'; import { compareHermesBytecode } from '../src/utils/hermes-base'; import { fuzzStringLiterals } from './hermes-fuzz-literals'; import { hermesFuzzSucceeded } from './hermes-fuzz-result'; @@ -580,7 +581,7 @@ interface Finding { async function main() { const rng = new Rng(SEED); const gen = new Gen(rng); - fs.ensureDirSync(OUT_DIR); + ensureDirSync(OUT_DIR); console.log( `fuzz-hermes-base: ${ROUNDS} rounds, seed ${SEED}, hermesc ${hermesc}, out ${OUT_DIR}`, ); @@ -598,7 +599,7 @@ async function main() { for (let round = 0; round < ROUNDS; round++) { const dir = path.join(OUT_DIR, `round-${String(round).padStart(4, '0')}`); - fs.ensureDirSync(dir); + ensureDirSync(dir); const next = gen.program(); const baseSource = pool.length > 0 && rng.chance(0.5) ? rng.pick(pool) : gen.mutate(next); @@ -712,7 +713,7 @@ async function main() { } } - if (!keep) fs.removeSync(dir); + if (!keep) removeSync(dir); } const seconds = ((Date.now() - started) / 1000).toFixed(1); @@ -751,7 +752,7 @@ async function main() { plantedCompileErrors, }); if (!ok) console.log(`\nfailing cases kept under ${OUT_DIR}`); - else if (!argValue('out')) fs.removeSync(OUT_DIR); + else if (!argValue('out')) removeSync(OUT_DIR); process.exit(ok ? 0 : 1); } diff --git a/src/bundle-pack.ts b/src/bundle-pack.ts index 885f158..c8b963d 100644 --- a/src/bundle-pack.ts +++ b/src/bundle-pack.ts @@ -1,6 +1,7 @@ -import * as fs from 'fs-extra'; +import fs from 'fs'; import path from 'path'; import { ZipFile as YazlZipFile } from 'yazl'; +import { ensureDirSync } from './utils/fs-helpers'; import { t } from './utils/i18n'; import { zipOptionsForPayloadFile } from './utils/zip-options'; @@ -19,7 +20,7 @@ export async function packBundle( ignorePackingFileNames.push(`${bundleName}.map`); } console.log(t('packing')); - fs.ensureDirSync(path.dirname(output)); + ensureDirSync(path.dirname(output)); await new Promise((resolve, reject) => { const zipfile = new YazlZipFile(); diff --git a/src/bundle-runner.ts b/src/bundle-runner.ts index 65085b7..808211c 100644 --- a/src/bundle-runner.ts +++ b/src/bundle-runner.ts @@ -1,10 +1,16 @@ import { spawn } from 'child_process'; import { satisfies } from 'compare-versions'; -import * as fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { getHermesBase } from './api'; import { getDepVersion } from './utils/dep-versions'; +import { + emptyDirSync, + ensureDirSync, + moveSync, + removeSync, +} from './utils/fs-helpers'; import { classifyHermesCommand, compareHermesBytecode, @@ -243,7 +249,7 @@ export async function runReactNativeBundleCommand({ } outputFolder = assertSafeToEmpty(outputFolder); - fs.emptyDirSync(outputFolder); + emptyDirSync(outputFolder); let cliPath = ''; let usingExpo = false; @@ -401,16 +407,16 @@ export async function runReactNativeBundleCommand({ if (platform === 'harmony') { const harmonyRawAssetsPath = 'harmony/entry/src/main/resources/rawfile/assets'; - fs.ensureDirSync(harmonyRawAssetsPath); - fs.copySync(outputFolder, harmonyRawAssetsPath, { - overwrite: true, + ensureDirSync(harmonyRawAssetsPath); + fs.cpSync(outputFolder, harmonyRawAssetsPath, { + recursive: true, + force: true, // sourcemaps must not ship inside the native package filter: (src) => !src.endsWith('.map'), }); - fs.moveSync( + moveSync( `${harmonyRawAssetsPath}/bundle.harmony.js`, `${harmonyRawAssetsPath}/../bundle.harmony.js`, - { overwrite: true }, ); } @@ -483,7 +489,7 @@ export function assertSafeToEmpty(dir: string, cwd = process.cwd()): string { // emptyDirSync creates a missing directory too; create it first so it can be // canonicalized and the exact validated path can be passed to deletion. - fs.ensureDirSync(requested); + ensureDirSync(requested); const canonical = (value: string) => fs.realpathSync.native(value); const target = canonical(requested); const canonicalCwd = canonical(lexicalCwd); @@ -1118,7 +1124,7 @@ export async function compileHermesByteCode({ const jsSource = path.join(workDir, bundleName); const plainPath = path.join(workDir, 'plain', bundleName); const wantPlain = Boolean(baseRequest?.verify); - fs.ensureDirSync(path.dirname(plainPath)); + ensureDirSync(path.dirname(plainPath)); fs.renameSync(bundlePath, jsSource); const args = hermescArgsWithBase( buildHermescArgs(bundlePath, jsSource), @@ -1243,15 +1249,15 @@ export async function compileHermesByteCode({ } if (!usedBase) { if (plainOk) { - fs.moveSync(plainPath, bundlePath, { overwrite: true }); + moveSync(plainPath, bundlePath); if (fs.existsSync(`${plainPath}.map`)) { - fs.moveSync(`${plainPath}.map`, hermesMap, { overwrite: true }); + moveSync(`${plainPath}.map`, hermesMap); } } else { await compilePlain(command, bundlePath, jsSource); } } - fs.removeSync(workDir); + removeSync(workDir); result.base = usedBase ? base : null; if (usedBase) result.outcome = 'used'; } @@ -1259,7 +1265,7 @@ export async function compileHermesByteCode({ await composeSourceMaps(packagerMap, hermesMap, sourcemapOutput); } if (shouldCleanSourcemap) { - fs.removeSync(packagerMap); + removeSync(packagerMap); } return result; } @@ -1407,7 +1413,7 @@ export async function copyDebugidForSentry( copyDebugidPath, ); } - fs.removeSync(path.join(outputFolder, `${bundleName}.txt.map`)); + removeSync(path.join(outputFolder, `${bundleName}.txt.map`)); } export async function prepareSentryUploadArtifacts( diff --git a/src/diff.ts b/src/diff.ts index c7df569..993a89d 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import * as fs from 'fs-extra'; +import fs from 'fs'; import { npm, yarn } from 'global-dirs'; import os from 'os'; import path from 'path'; @@ -9,6 +9,7 @@ import { resolveNativePackageEntry } from './native-package'; import type { CommandContext, Platform } from './types'; import { translateOptions } from './utils'; import { isPPKBundleFileName, scriptName, tempDir } from './utils/constants'; +import { ensureDirSync } from './utils/fs-helpers'; import { type HbcTransformMeta, transformHbcFile, @@ -132,7 +133,7 @@ function basename(fn: string): string | undefined { } function createOutputZip(output: string) { - fs.ensureDirSync(path.dirname(output)); + ensureDirSync(path.dirname(output)); const zipfile = new YazlZipFile(); const writeStream = fs.createWriteStream(output); const writePromise = new Promise((resolve, reject) => { @@ -158,7 +159,7 @@ function createOutputZip(output: string) { } }); } - await fs.rm(output, { force: true }); + await fs.promises.rm(output, { force: true }); }; return { zipfile, writePromise, abort }; } @@ -228,7 +229,7 @@ const bundleSourceSize = (source: BundleSource) => source.kind === 'buffer' ? source.data.length : source.size; const bundleSourceBuffer = async (source: BundleSource) => - source.kind === 'buffer' ? source.data : fs.readFile(source.path); + source.kind === 'buffer' ? source.data : fs.promises.readFile(source.path); async function bundleSourceFile( source: BundleSource, @@ -237,7 +238,7 @@ async function bundleSourceFile( if (source.kind === 'file') { return source.path; } - await fs.writeFile(outputPath, source.data); + await fs.promises.writeFile(outputPath, source.data); return outputPath; } @@ -251,8 +252,8 @@ async function hashFile(filePath: string): Promise { async function filesEqual(leftPath: string, rightPath: string) { const [leftStat, rightStat] = await Promise.all([ - fs.stat(leftPath), - fs.stat(rightPath), + fs.promises.stat(leftPath), + fs.promises.stat(rightPath), ]); if (leftStat.size !== rightStat.size) { return false; @@ -322,7 +323,7 @@ async function buildStreamBundlePatch( const rawPatchFile = path.join(tempRoot, 'patch-raw.bin'); await diffStreamFn(rawOldFile, rawNewFile, rawPatchFile); let patchFile = rawPatchFile; - let patchBytes = (await fs.stat(rawPatchFile)).size; + let patchBytes = (await fs.promises.stat(rawPatchFile)).size; let chosenPair: typeof pair = null; if (pair) { const transformedPatchFile = path.join(tempRoot, 'patch-transformed.bin'); @@ -331,7 +332,8 @@ async function buildStreamBundlePatch( transformedNewFile, transformedPatchFile, ); - const transformedPatchBytes = (await fs.stat(transformedPatchFile)).size; + const transformedPatchBytes = (await fs.promises.stat(transformedPatchFile)) + .size; const metaOverhead = Buffer.byteLength(JSON.stringify(pair.meta)); if (transformedPatchBytes + metaOverhead < patchBytes) { patchFile = transformedPatchFile; diff --git a/src/native-package.ts b/src/native-package.ts index 4456dde..668444c 100644 --- a/src/native-package.ts +++ b/src/native-package.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { @@ -8,6 +8,7 @@ import { } from 'yauzl'; import { ZipFile as YazlZipFile } from 'yazl'; import type { Platform } from './types'; +import { ensureDir, remove } from './utils/fs-helpers'; import { fitsRawZip, RawZipWriter } from './utils/zip-raw-writer'; export type NativePackageEntry = { @@ -164,7 +165,7 @@ class RepackSink implements SlimSink { async abort(): Promise { this.outputZip.end(); await this.writePromise.catch(() => {}); - await fs.remove(this.output).catch(() => {}); + await remove(this.output).catch(() => {}); } } @@ -216,8 +217,8 @@ async function filterNativePackageArchive( platform: Platform, options: SlimPackageOptions, ): Promise<{ bundles: number; entries: number }> { - await fs.ensureDir(path.dirname(output)); - const scratchDir = await fs.mkdtemp( + await ensureDir(path.dirname(output)); + const scratchDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'rnu-native-package-entry-'), ); const sourceZip = await openZip(source); @@ -225,7 +226,7 @@ async function filterNativePackageArchive( // entry count decide whether plain (non-zip64) headers are enough const rawCopy = !options.repack && - fitsRawZip((await fs.stat(source)).size, sourceZip.entryCount); + fitsRawZip((await fs.promises.stat(source)).size, sourceZip.entryCount); const sink: SlimSink = rawCopy ? new RawCopySink(output) : new RepackSink(output); @@ -311,7 +312,7 @@ async function filterNativePackageArchive( await sink.abort(); throw error; } finally { - await fs.remove(scratchDir); + await remove(scratchDir); } } @@ -332,7 +333,7 @@ export async function createSlimNativePackage( options, ); if (result.bundles === 0) { - await fs.remove(output); + await remove(output); throw new Error(`Bundle entry not found in native package: ${source}`); } } diff --git a/src/package.ts b/src/package.ts index 48bceee..09d75c0 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { doDelete, getAllPackages, post, uploadFile } from './api'; @@ -16,6 +16,7 @@ import { } from './utils'; import { updateJson } from './utils/constants'; import { getDepVersions } from './utils/dep-versions'; +import { remove } from './utils/fs-helpers'; import { getCommitInfo } from './utils/git'; import { bundleEntryMatcher, @@ -161,7 +162,7 @@ async function uploadNativePackage( console.log(t('usingCustomVersion', { version: versionName })); } - const tempRoot = await fs.mkdtemp( + const tempRoot = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'rnu-native-package-upload-'), ); const slimPackagePath = path.join(tempRoot, `package${config.extension}`); @@ -217,7 +218,7 @@ async function uploadNativePackage( }), ); } finally { - await fs.remove(tempRoot); + await remove(tempRoot); } } @@ -324,7 +325,7 @@ export const packageCommands = { // private temp dir: unpredictable path, safe against symlink squatting in // the shared tmpdir and against concurrent uploads of same-named AABs - const tempRoot = await fs.mkdtemp( + const tempRoot = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'rnu-aab-upload-'), ); const output = path.join(tempRoot, 'universal.apk'); @@ -343,7 +344,7 @@ export const packageCommands = { options, }); } finally { - await fs.remove(tempRoot); + await remove(tempRoot); } }, uploadApp: async ({ diff --git a/src/utils/app-info-parser/aab.ts b/src/utils/app-info-parser/aab.ts index 49040ab..fe2f94e 100644 --- a/src/utils/app-info-parser/aab.ts +++ b/src/utils/app-info-parser/aab.ts @@ -1,8 +1,9 @@ import { spawn } from 'child_process'; -import * as fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { open as openZipFile } from 'yauzl'; +import { pathExists, remove } from '../fs-helpers'; import { t } from '../i18n'; import { ResourceFinder } from './resource-finder'; import { mapInfoResource } from './utils'; @@ -57,7 +58,9 @@ export class AabParser extends Zip { }); // Create a temp file for the .apks output - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bundletool-')); + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'bundletool-'), + ); const tempApksPath = path.join(tempDir, 'output.apks'); const needsNpxDownload = async () => { @@ -163,8 +166,8 @@ export class AabParser extends Zip { }); } finally { // Cleanup - if (await fs.pathExists(tempDir)) { - await fs.remove(tempDir); + if (await pathExists(tempDir)) { + await remove(tempDir); } } } diff --git a/src/utils/fs-helpers.ts b/src/utils/fs-helpers.ts new file mode 100644 index 0000000..7d2d292 --- /dev/null +++ b/src/utils/fs-helpers.ts @@ -0,0 +1,56 @@ +// The handful of fs-extra conveniences the CLI used, on plain node:fs. + +import fs from 'fs'; +import path from 'path'; + +/** `rm -rf`: no error when the path is already gone. */ +export function remove(target: string): Promise { + return fs.promises.rm(target, { recursive: true, force: true }); +} + +export function removeSync(target: string): void { + fs.rmSync(target, { recursive: true, force: true }); +} + +export async function ensureDir(dir: string): Promise { + await fs.promises.mkdir(dir, { recursive: true }); +} + +export function ensureDirSync(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +export function pathExists(target: string): Promise { + return fs.promises.access(target).then( + () => true, + () => false, + ); +} + +/** Deletes everything inside `dir`, creating it when missing. */ +export function emptyDirSync(dir: string): void { + let names: string[]; + try { + names = fs.readdirSync(dir); + } catch { + ensureDirSync(dir); + return; + } + for (const name of names) removeSync(path.join(dir, name)); +} + +/** + * Moves `src` to `dest`, replacing it. Creates the parent of `dest`, and + * falls back to copy + delete when the two are on different devices. + */ +export function moveSync(src: string, dest: string): void { + ensureDirSync(path.dirname(dest)); + try { + fs.renameSync(src, dest); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + removeSync(dest); + fs.cpSync(src, dest, { recursive: true }); + removeSync(src); + } +} diff --git a/src/utils/hermes-base.ts b/src/utils/hermes-base.ts index 418a700..16ca52f 100644 --- a/src/utils/hermes-base.ts +++ b/src/utils/hermes-base.ts @@ -12,13 +12,20 @@ import { StringDecoder } from 'node:string_decoder'; import { spawn, spawnSync } from 'child_process'; import { createHash } from 'crypto'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { PassThrough, Readable } from 'stream'; import { pipeline } from 'stream/promises'; import { tempDir } from './constants'; import { failureFingerprint, redactFailureDetail } from './failure-fingerprint'; +import { + ensureDir, + ensureDirSync, + pathExists, + remove, + removeSync, +} from './fs-helpers'; import { getHbcVersion } from './hbcTransform'; import { normalizeCachedObjectInstruction } from './hermes-cached-object'; import { @@ -255,14 +262,14 @@ export async function sha256File(file: string): Promise { /** HBC version from the first bytes of a file, without reading the rest */ export async function readFileHbcVersion(file: string): Promise { const header = Buffer.alloc(128); - const fd = await fs.open(file, 'r'); + const handle = await fs.promises.open(file, 'r'); try { - const { bytesRead } = await fs.read(fd, header, 0, 128, 0); + const { bytesRead } = await handle.read(header, 0, 128, 0); return getHbcVersion(header.subarray(0, bytesRead)); } catch { return null; } finally { - await fs.close(fd); + await handle.close(); } } @@ -297,7 +304,7 @@ function writeProbeCache(entries: Record): void { const trimmed = Object.fromEntries( keys.slice(-PROBE_CACHE_ENTRIES).map((key) => [key, entries[key]]), ); - fs.ensureDirSync(cacheDir()); + ensureDirSync(cacheDir()); fs.writeFileSync( path.join(cacheDir(), PROBE_CACHE_FILE), JSON.stringify(trimmed), @@ -359,7 +366,7 @@ function compileProbe(hermesCommand: string): number | null { } catch { return null; } finally { - fs.removeSync(dir); + removeSync(dir); } } @@ -402,7 +409,7 @@ export async function extractBundleFromArchive( ext === '.bundlejs' || ext === '.js' ) { - return fs.readFile(filePath); + return fs.promises.readFile(filePath); } if (ext === '.ppk' || ext === '.apk' || ext === '.ipa') { return readFirstZipEntry( @@ -423,7 +430,7 @@ export async function extractBundleFromArchive( const fromZip = await readFirstZipEntry(filePath, (name) => BUNDLE_ENTRY_NAMES.includes(name), ).catch(() => null); - return fromZip ?? fs.readFile(filePath); + return fromZip ?? fs.promises.readFile(filePath); } async function readFirstZipEntry( @@ -469,14 +476,14 @@ export function cacheLimits(overrideMaxMb?: number) { export async function cacheLookup(bundleHash: string): Promise { if (!/^[0-9a-f]{64}$/.test(bundleHash)) return null; const file = path.join(cacheDir(), bundleHash); - if (!(await fs.pathExists(file))) return null; + if (!(await pathExists(file))) return null; if ((await sha256File(file).catch(() => '')) !== bundleHash) { // corrupt entry: never trust cache content - await fs.remove(file).catch(() => {}); + await remove(file).catch(() => {}); return null; } const now = new Date(); - await fs.utimes(file, now, now).catch(() => {}); + await fs.promises.utimes(file, now, now).catch(() => {}); return file; } @@ -488,15 +495,15 @@ export async function cachePut( ): Promise<{ path: string; bundleHash: string }> { const bundleHash = knownHash ?? sha256Hex(bundle); const dir = cacheDir(); - await fs.ensureDir(dir); + await ensureDir(dir); const file = path.join(dir, bundleHash); - if (!(await fs.pathExists(file))) { + if (!(await pathExists(file))) { const tmp = `${file}.${process.pid}.tmp`; - await fs.writeFile(tmp, bundle); - await fs.rename(tmp, file); + await fs.promises.writeFile(tmp, bundle); + await fs.promises.rename(tmp, file); } else { const now = new Date(); - await fs.utimes(file, now, now).catch(() => {}); + await fs.promises.utimes(file, now, now).catch(() => {}); } await enforceCacheLimits(maxMb, bundleHash); return { path: file, bundleHash }; @@ -507,19 +514,19 @@ export async function enforceCacheLimits( keep?: string, ): Promise { const dir = cacheDir(); - if (!(await fs.pathExists(dir))) return; + if (!(await pathExists(dir))) return; const { maxBytes, maxFiles } = cacheLimits(maxMb); const entries: { name: string; size: number; mtime: number }[] = []; const staleTmpCutoff = Date.now() - STALE_CACHE_TMP_MS; - for (const name of await fs.readdir(dir)) { + for (const name of await fs.promises.readdir(dir)) { const isTmp = isCacheTmpName(name); if (!isTmp && !/^[0-9a-f]{64}$/.test(name)) continue; - const stat = await fs.stat(path.join(dir, name)).catch(() => null); + const stat = await fs.promises.stat(path.join(dir, name)).catch(() => null); if (!stat?.isFile()) continue; if (isTmp) { // an interrupted cachePut; nothing will ever rename it into place if (stat.mtimeMs < staleTmpCutoff) - await fs.remove(path.join(dir, name)).catch(() => {}); + await remove(path.join(dir, name)).catch(() => {}); continue; } entries.push({ name, size: stat.size, mtime: stat.mtimeMs }); @@ -531,7 +538,7 @@ export async function enforceCacheLimits( total += entry.size; count += 1; if (entry.name !== keep && (total > maxBytes || count > maxFiles)) { - await fs.remove(path.join(dir, entry.name)).catch(() => {}); + await remove(path.join(dir, entry.name)).catch(() => {}); } } } @@ -544,12 +551,12 @@ function isCacheTmpName(name: string): boolean { /** Remove every cached bundle (and staging leftovers); returns the bundle count. */ export async function cleanCache(): Promise { const dir = cacheDir(); - if (!(await fs.pathExists(dir))) return 0; + if (!(await pathExists(dir))) return 0; let bundles = 0; - for (const name of await fs.readdir(dir)) { + for (const name of await fs.promises.readdir(dir)) { const isBundle = /^[0-9a-f]{64}$/.test(name); if (!isBundle && !isCacheTmpName(name)) continue; - await fs.remove(path.join(dir, name)); + await remove(path.join(dir, name)); if (isBundle) bundles += 1; } return bundles; @@ -563,10 +570,12 @@ export async function cacheStats(): Promise<{ const dir = cacheDir(); let files = 0; let bytes = 0; - if (await fs.pathExists(dir)) { - for (const name of await fs.readdir(dir)) { + if (await pathExists(dir)) { + for (const name of await fs.promises.readdir(dir)) { if (!/^[0-9a-f]{64}$/.test(name)) continue; - const stat = await fs.stat(path.join(dir, name)).catch(() => null); + const stat = await fs.promises + .stat(path.join(dir, name)) + .catch(() => null); if (stat?.isFile()) { files += 1; bytes += stat.size; @@ -581,12 +590,12 @@ export async function cleanStaleTmp( maxAgeMs = 24 * 60 * 60 * 1000, ): Promise { const dir = tmpDir(); - if (!(await fs.pathExists(dir))) return; + if (!(await pathExists(dir))) return; const cutoff = Date.now() - maxAgeMs; - for (const name of await fs.readdir(dir)) { + for (const name of await fs.promises.readdir(dir)) { const file = path.join(dir, name); - const stat = await fs.stat(file).catch(() => null); - if (stat && stat.mtimeMs < cutoff) await fs.remove(file).catch(() => {}); + const stat = await fs.promises.stat(file).catch(() => null); + if (stat && stat.mtimeMs < cutoff) await remove(file).catch(() => {}); } } @@ -670,7 +679,7 @@ async function saveResponse( controller = new AbortController(), ): Promise { if (!response.body) throw new Error('empty response body'); - await fs.ensureDir(path.dirname(destination)); + await ensureDir(path.dirname(destination)); const abort = () => controller.abort( new Error(`download stalled for ${STREAM_IDLE_TIMEOUT_MS / 1000}s`), @@ -965,7 +974,7 @@ export async function resolveHermesBase( // 2. download, extract, verify, cache — transport errors get a second try for (let attempt = 1; attempt <= 2; attempt++) { const dir = tmpDir(); - await fs.ensureDir(dir); + await ensureDir(dir); const artifactType: BaseArtifactType = [ 'ppk', 'apk', @@ -1033,7 +1042,7 @@ export async function resolveHermesBase( return null; } } finally { - await fs.remove(archive).catch(() => {}); + await remove(archive).catch(() => {}); } } return null; diff --git a/src/utils/plugin-config.ts b/src/utils/plugin-config.ts index 699c339..7af9d8f 100644 --- a/src/utils/plugin-config.ts +++ b/src/utils/plugin-config.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs-extra'; +import fs from 'fs'; interface PluginConfig { name: string; @@ -17,11 +17,11 @@ export const plugins: PluginConfig[] = [ }, detect: async () => { try { - await fs.access('ios/sentry.properties'); + await fs.promises.access('ios/sentry.properties'); return true; } catch { try { - await fs.access('android/sentry.properties'); + await fs.promises.access('android/sentry.properties'); return true; } catch { return false; diff --git a/src/utils/zip-entries.ts b/src/utils/zip-entries.ts index 3ceeeb0..a57bf6e 100644 --- a/src/utils/zip-entries.ts +++ b/src/utils/zip-entries.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { pipeline } from 'stream/promises'; @@ -7,6 +7,7 @@ import { open as openZipFile, type ZipFile as YauzlZipFile, } from 'yauzl'; +import { ensureDirSync, remove } from './fs-helpers'; export function readEntry( entry: Entry, @@ -40,7 +41,7 @@ export function writeEntry( zipFile: YauzlZipFile, outputPath: string, ): Promise { - fs.ensureDirSync(path.dirname(outputPath)); + ensureDirSync(path.dirname(outputPath)); return new Promise((resolve, reject) => { zipFile.openReadStream(entry, (err, stream) => { if (err) { @@ -165,7 +166,7 @@ export async function enumZipEntries( !entry.fileName.endsWith('/') && entry.fileName.toLowerCase().endsWith('.hap') ) { - const tempDir = await fs.mkdtemp( + const tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'nested_zip_'), ); const tempZipPath = path.join(tempDir, 'temp.zip'); @@ -183,7 +184,7 @@ export async function enumZipEntries( await enumZipEntries(tempZipPath, callback, `${fullPath}/`); } finally { - await fs.remove(tempDir); + await remove(tempDir); } } diff --git a/src/utils/zip-range.ts b/src/utils/zip-range.ts index f0db5c9..c9fa369 100644 --- a/src/utils/zip-range.ts +++ b/src/utils/zip-range.ts @@ -6,7 +6,7 @@ // `openRemoteZip` read just the bundle of a remote archive through HTTP Range // requests instead of downloading the whole ppk / apk / ipa. Every helper // throws on anything unexpected; callers fall back to a full download. -import fs from 'fs-extra'; +import fs from 'fs'; import { PassThrough, Readable } from 'stream'; import { type Entry, @@ -85,10 +85,9 @@ async function locationOfEntry( entry: Entry, ): Promise { const header = Buffer.alloc(LOCAL_HEADER_SIZE); - const fd = await fs.open(zipPath, 'r'); + const handle = await fs.promises.open(zipPath, 'r'); try { - const { bytesRead } = await fs.read( - fd, + const { bytesRead } = await handle.read( header, 0, LOCAL_HEADER_SIZE, @@ -98,7 +97,7 @@ async function locationOfEntry( throw new Error('truncated local file header'); } } finally { - await fs.close(fd); + await handle.close(); } if (header.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { throw new Error('invalid local file header signature'); diff --git a/src/utils/zip-raw-writer.ts b/src/utils/zip-raw-writer.ts index f7cb34c..ebbad21 100644 --- a/src/utils/zip-raw-writer.ts +++ b/src/utils/zip-raw-writer.ts @@ -4,9 +4,10 @@ // a second deflate of every asset (yazl can only add uncompressed data and // re-deflates it). Plain zip only: no zip64, no data descriptors, no extra // fields; callers must check `fitsRawZip` before choosing this writer. -import fs from 'fs-extra'; +import fs from 'fs'; import type { Readable } from 'stream'; import type { Entry } from 'yauzl'; +import { remove } from './fs-helpers'; const LOCAL_HEADER_SIGNATURE = 0x04034b50; const CENTRAL_HEADER_SIGNATURE = 0x02014b50; @@ -234,7 +235,7 @@ export class RawZipWriter { async abort(): Promise { this.out.destroy(); await this.closed.catch(() => {}); - await fs.remove(this.output).catch(() => {}); + await remove(this.output).catch(() => {}); } private write(chunk: Buffer): Promise { diff --git a/tests/fs-helpers.test.ts b/tests/fs-helpers.test.ts new file mode 100644 index 0000000..15a1c51 --- /dev/null +++ b/tests/fs-helpers.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + emptyDirSync, + ensureDir, + ensureDirSync, + moveSync, + pathExists, + remove, + removeSync, +} from '../src/utils/fs-helpers'; + +let dir: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-fs-helpers-')); +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('fs-helpers', () => { + test('remove and removeSync delete trees and ignore missing paths', async () => { + ensureDirSync(path.join(dir, 'a/b')); + fs.writeFileSync(path.join(dir, 'a/b/c.txt'), 'c'); + await remove(path.join(dir, 'a')); + expect(fs.existsSync(path.join(dir, 'a'))).toBe(false); + await remove(path.join(dir, 'missing')); + removeSync(path.join(dir, 'missing')); + }); + + test('ensureDir creates nested directories and tolerates existing ones', async () => { + const nested = path.join(dir, 'x/y/z'); + await ensureDir(nested); + await ensureDir(nested); + ensureDirSync(nested); + expect(fs.statSync(nested).isDirectory()).toBe(true); + }); + + test('pathExists', async () => { + expect(await pathExists(dir)).toBe(true); + expect(await pathExists(path.join(dir, 'missing'))).toBe(false); + }); + + test('emptyDirSync clears a directory and creates a missing one', () => { + ensureDirSync(path.join(dir, 'out/sub')); + fs.writeFileSync(path.join(dir, 'out/file.js'), 'x'); + fs.writeFileSync(path.join(dir, 'out/sub/file.map'), 'x'); + emptyDirSync(path.join(dir, 'out')); + expect(fs.readdirSync(path.join(dir, 'out'))).toEqual([]); + + emptyDirSync(path.join(dir, 'new/deep')); + expect(fs.readdirSync(path.join(dir, 'new/deep'))).toEqual([]); + }); + + test('moveSync replaces the destination and creates its parent', () => { + fs.writeFileSync(path.join(dir, 'src.js'), 'new'); + moveSync(path.join(dir, 'src.js'), path.join(dir, 'a/b/dest.js')); + expect(fs.readFileSync(path.join(dir, 'a/b/dest.js'), 'utf8')).toBe('new'); + expect(fs.existsSync(path.join(dir, 'src.js'))).toBe(false); + + fs.writeFileSync(path.join(dir, 'src.js'), 'newer'); + moveSync(path.join(dir, 'src.js'), path.join(dir, 'a/b/dest.js')); + expect(fs.readFileSync(path.join(dir, 'a/b/dest.js'), 'utf8')).toBe( + 'newer', + ); + }); +}); diff --git a/tests/hermes-base.test.ts b/tests/hermes-base.test.ts index cba1f74..5ee5523 100644 --- a/tests/hermes-base.test.ts +++ b/tests/hermes-base.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { ZipFile } from 'yazl'; +import { ensureDirSync, removeSync } from '../src/utils/fs-helpers'; import { cacheDir, cacheLookup, @@ -77,7 +78,7 @@ describe('classifyHermesCommand', () => { root = mkTemp('rnu-hermes-gate-'); }); afterEach(() => { - fs.removeSync(root); + removeSync(root); }); test('classic hermesc locations are allowed', () => { @@ -105,11 +106,14 @@ describe('classifyHermesCommand', () => { ]; for (const [version, allowed] of cases) { const pkg = path.join(root, version, 'node_modules', 'hermes-compiler'); - fs.ensureDirSync(path.join(pkg, 'hermesc', 'osx-bin')); - fs.writeJsonSync(path.join(pkg, 'package.json'), { - name: 'hermes-compiler', - version, - }); + ensureDirSync(path.join(pkg, 'hermesc', 'osx-bin')); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ + name: 'hermes-compiler', + version, + }), + ); const result = classifyHermesCommand( path.join(pkg, 'hermesc', 'osx-bin', 'hermesc'), ); @@ -139,7 +143,7 @@ describe('bundle cache', () => { else process.env.PUSHY_CACHE_DIR = previous.cache; if (previous.max === undefined) delete process.env.PUSHY_CACHE_MAX_MB; else process.env.PUSHY_CACHE_MAX_MB = previous.max; - fs.removeSync(dir); + removeSync(dir); }); test('put/lookup round-trips by sha256 and rejects corrupted content', async () => { @@ -170,7 +174,7 @@ describe('bundle cache', () => { test('cleanStaleTmp removes only old leftovers', async () => { const tmp = tmpDir(); - fs.ensureDirSync(tmp); + ensureDirSync(tmp); const old = path.join(tmp, 'old.ppk'); const fresh = path.join(tmp, 'fresh.ppk'); fs.writeFileSync(old, 'x'); @@ -180,7 +184,7 @@ describe('bundle cache', () => { await cleanStaleTmp(); expect(fs.existsSync(old)).toBe(false); expect(fs.existsSync(fresh)).toBe(true); - fs.removeSync(tmp); + removeSync(tmp); }); }); @@ -190,7 +194,7 @@ describe('extractBundleFromArchive', () => { dir = mkTemp('rnu-hermes-extract-'); }); afterEach(() => { - fs.removeSync(dir); + removeSync(dir); }); test('reads the bundle entry of a ppk and an apk', async () => { @@ -233,7 +237,7 @@ describe('resolveHermesBase', () => { afterEach(() => { if (previousCache === undefined) delete process.env.PUSHY_CACHE_DIR; else process.env.PUSHY_CACHE_DIR = previousCache; - fs.removeSync(dir); + removeSync(dir); }); const common = { hermesCommand: 'hermesc', @@ -453,7 +457,7 @@ describe('resolveHermesBase over HTTP Range', () => { } finally { server.stop(true); delete process.env.PUSHY_CACHE_DIR; - fs.removeSync(dir); + removeSync(dir); } }); @@ -508,7 +512,7 @@ describe('resolveHermesBase over HTTP Range', () => { } finally { server.stop(true); delete process.env.PUSHY_CACHE_DIR; - fs.removeSync(dir); + removeSync(dir); } }); @@ -544,7 +548,7 @@ describe('resolveHermesBase over HTTP Range', () => { expect(hit?.source).toBe('cache'); } finally { delete process.env.PUSHY_CACHE_DIR; - fs.removeSync(dir); + removeSync(dir); } }); }); @@ -569,7 +573,7 @@ describe('cache staging leftovers', () => { expect(fs.existsSync(fresh)).toBe(false); } finally { delete process.env.PUSHY_CACHE_DIR; - fs.removeSync(dir); + removeSync(dir); } }); }); @@ -744,7 +748,7 @@ describe.if(hasHermesc)('with a real hermesc', () => { afterEach(() => { if (previousCache === undefined) delete process.env.PUSHY_CACHE_DIR; else process.env.PUSHY_CACHE_DIR = previousCache; - fs.removeSync(dir); + removeSync(dir); }); test('probeHbcVersion returns the bytecode version', () => { @@ -855,7 +859,7 @@ describe('publish metadata never sends JSON null', () => { }); expect(plainWithBase.bytecodeVersion).toBe(0); } finally { - fs.removeSync(dir); + removeSync(dir); } }); }); @@ -972,7 +976,7 @@ describe('probeHbcVersion cache', () => { afterEach(() => { if (previous === undefined) delete process.env.PUSHY_CACHE_DIR; else process.env.PUSHY_CACHE_DIR = previous; - fs.removeSync(dir); + removeSync(dir); }); test.if(os.platform() !== 'win32')( @@ -1109,7 +1113,7 @@ describe.if(os.platform() !== 'win32')( ); fs.chmodSync(fakeHermesc, 0o755); }); - afterEach(() => fs.removeSync(dir)); + afterEach(() => removeSync(dir)); test('matching text dumps without readable HBC metadata are unverifiable', async () => { const result = await compareHermesBytecode( @@ -1258,7 +1262,7 @@ describe.if(os.platform() !== 'win32')( withBase: path.join(dir, 'out', 'base.txt'), plain: path.join(dir, 'out', 'plain.txt'), }; - fs.ensureDirSync(path.dirname(dumpTo.withBase)); + ensureDirSync(path.dirname(dumpTo.withBase)); const result = await compareHermesBytecode( fakeHermesc, write('delta.hbc', DELTA_DUMP), @@ -1279,7 +1283,7 @@ describe.if(hasHermesc)('compareHermesBytecode with a real hermesc', () => { beforeEach(() => { dir = mkTemp('rnu-hermes-real-cmp-'); }); - afterEach(() => fs.removeSync(dir)); + afterEach(() => removeSync(dir)); // enough identifiers and literals that the delta build has to use the // wide operand forms (Long/LongIndex) and re-lay the literal buffers diff --git a/tests/hermes-blockers.test.ts b/tests/hermes-blockers.test.ts index c66c888..72e0475 100644 --- a/tests/hermes-blockers.test.ts +++ b/tests/hermes-blockers.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { removeSync } from '../src/utils/fs-helpers'; import { normalizeDisassemblyLine } from '../src/utils/hermes-base'; import type { LiteralBuffers } from '../src/utils/hermes-literals'; import { @@ -110,7 +111,7 @@ describe.if(process.platform !== 'win32')('debug dump failure cleanup', () => { { mode: 0o755 }, ); }); - afterEach(() => fs.removeSync(dir)); + afterEach(() => removeSync(dir)); for (const scenario of ['timeout', 'abort', 'pre-abort', 'ENOENT'] as const) { test(`${scenario} with dumpTo returns and closes debug streams`, () => { diff --git a/tests/hermes-compile.test.ts b/tests/hermes-compile.test.ts index b0a3467..bb4b328 100644 --- a/tests/hermes-compile.test.ts +++ b/tests/hermes-compile.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { @@ -8,6 +8,7 @@ import { hermesBaseErrorLogPath, startHermesBaseSelection, } from '../src/bundle-runner'; +import { ensureDirSync, removeSync } from '../src/utils/fs-helpers'; import { getHbcVersion } from '../src/utils/hbcTransform'; import { probeHbcVersion } from '../src/utils/hermes-base'; @@ -40,7 +41,7 @@ describe.if(hasHermesc)('compileHermesByteCode with a base', () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-hermes-compile-')); process.env.PUSHY_CACHE_DIR = path.join(dir, 'cache'); outputFolder = path.join(dir, 'out'); - fs.ensureDirSync(outputFolder); + ensureDirSync(outputFolder); fs.writeFileSync(path.join(outputFolder, bundleName), NEXT_SRC); const baseJs = path.join(dir, 'base.js'); fs.writeFileSync(baseJs, BASE_SRC); @@ -55,7 +56,7 @@ describe.if(hasHermesc)('compileHermesByteCode with a base', () => { afterEach(() => { if (previousCache === undefined) delete process.env.PUSHY_CACHE_DIR; else process.env.PUSHY_CACHE_DIR = previousCache; - fs.removeSync(dir); + removeSync(dir); }); // anything but the bundle and its hermes map is a leftover @@ -149,7 +150,7 @@ describe.if(hasHermesc)('compileHermesByteCode with a base', () => { dir, 'node_modules/react-native/sdks/hermesc/linux64-bin', ); - fs.ensureDirSync(wrapperDir); + ensureDirSync(wrapperDir); const wrapper = path.join(wrapperDir, 'hermesc'); const calls = path.join(dir, 'hermesc-calls.log'); fs.writeFileSync( @@ -204,7 +205,7 @@ exec "${hermesc}" "$@" dir, 'node_modules/react-native/sdks/hermesc/linux64-bin', ); - fs.ensureDirSync(wrapperDir); + ensureDirSync(wrapperDir); const wrapper = path.join(wrapperDir, 'hermesc'); const calls = path.join(dir, 'deadline-calls'); fs.writeFileSync( @@ -254,12 +255,15 @@ exec "${hermesc}" "$@" 'node_modules/react-native/sdks/hermesc/linux64-bin', ); const scripts = path.join(dir, 'node_modules/react-native/scripts'); - fs.ensureDirSync(wrapperDir); - fs.ensureDirSync(scripts); - fs.writeJsonSync(path.join(dir, 'node_modules/react-native/package.json'), { - name: 'react-native', - version: '0.77.3', - }); + ensureDirSync(wrapperDir); + ensureDirSync(scripts); + fs.writeFileSync( + path.join(dir, 'node_modules/react-native/package.json'), + JSON.stringify({ + name: 'react-native', + version: '0.77.3', + }), + ); const marker = path.join(dir, 'compose-attempted'); // The discarded base's composer fails immediately. After verification // fails, composing the retained plain output must still succeed. diff --git a/tests/hermes-literals.test.ts b/tests/hermes-literals.test.ts index 33ec9ca..a0f49d5 100644 --- a/tests/hermes-literals.test.ts +++ b/tests/hermes-literals.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { removeSync } from '../src/utils/fs-helpers'; import { LITERAL_SEPARATOR, normalizeDisassemblyLine, @@ -363,7 +364,7 @@ describe('readLiteralBuffers', () => { fs.writeFileSync(file, Buffer.from('not hbc')); expect(await readLiteralBuffers(file)).toBeNull(); } finally { - fs.removeSync(dir); + removeSync(dir); } }); @@ -428,7 +429,7 @@ describe('readLiteralBuffers', () => { expect(keys).toEqual(['[String "k"]', '[String "m"]']); expect(values).toEqual(['true', '[String "color"]']); } finally { - fs.removeSync(dir); + removeSync(dir); } }); } diff --git a/tests/hermes-metro.test.ts b/tests/hermes-metro.test.ts index ebe0a1d..b54b989 100644 --- a/tests/hermes-metro.test.ts +++ b/tests/hermes-metro.test.ts @@ -1,9 +1,10 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; import { createHash } from 'crypto'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { removeSync } from '../src/utils/fs-helpers'; import { compareHermesBytecode } from '../src/utils/hermes-base'; import { readHermesSemanticData } from '../src/utils/hermes-raw'; @@ -55,7 +56,7 @@ describe.if(Boolean(fixtures))('real Metro bundle verification', () => { delta = compile('s3-medium-feature.jsbundle', 'delta.hbc', base); }, 120_000); afterAll(() => { - if (dir) fs.removeSync(dir); + if (dir) removeSync(dir); }); test('self-comparison includes legal zero-byte Static Hermes functions', async () => { diff --git a/tests/hermes-raw.test.ts b/tests/hermes-raw.test.ts index 54980af..132bdef 100644 --- a/tests/hermes-raw.test.ts +++ b/tests/hermes-raw.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; import { createHash } from 'crypto'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { removeSync } from '../src/utils/fs-helpers'; import { compareHermesBytecode, probeHbcVersion, @@ -35,7 +36,7 @@ describe.if(hasHermesc)('lossless Hermes operand audit (real compiler)', () => { beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-hermes-raw-')); }); - afterEach(() => fs.removeSync(dir)); + afterEach(() => removeSync(dir)); const compile = ( name: string, diff --git a/tests/hermes-timeout.test.ts b/tests/hermes-timeout.test.ts index 91ffa96..f97d660 100644 --- a/tests/hermes-timeout.test.ts +++ b/tests/hermes-timeout.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { removeSync } from '../src/utils/fs-helpers'; import { compareHermesBytecode, probeHbcVersion, @@ -49,7 +50,7 @@ describe.if(process.platform !== 'win32')('Hermes subprocess deadlines', () => { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } - fs.removeSync(dir); + removeSync(dir); }); test('a hung synchronous version probe times out', () => { diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 1f34f6b..c0eb2f6 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -1,19 +1,21 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; - +import { ensureDir, remove } from '../src/utils/fs-helpers'; import { plugins } from '../src/utils/plugin-config'; describe('plugin-config - sentry plugin', () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-config-test-')); + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'plugin-config-test-'), + ); }); afterEach(async () => { - await fs.remove(tmpDir); + await remove(tmpDir); }); const sentryPlugin = plugins.find((p) => p.name === 'sentry'); @@ -42,8 +44,8 @@ describe('plugin-config - sentry plugin', () => { }); test('returns true when ios/sentry.properties exists', async () => { - await fs.ensureDir(path.join(tmpDir, 'ios')); - await fs.writeFile( + await ensureDir(path.join(tmpDir, 'ios')); + await fs.promises.writeFile( path.join(tmpDir, 'ios', 'sentry.properties'), 'defaults.org=test\n', ); @@ -59,8 +61,8 @@ describe('plugin-config - sentry plugin', () => { }); test('returns true when android/sentry.properties exists', async () => { - await fs.ensureDir(path.join(tmpDir, 'android')); - await fs.writeFile( + await ensureDir(path.join(tmpDir, 'android')); + await fs.promises.writeFile( path.join(tmpDir, 'android', 'sentry.properties'), 'defaults.org=test\n', ); @@ -76,13 +78,13 @@ describe('plugin-config - sentry plugin', () => { }); test('returns true when both ios and android sentry.properties exist', async () => { - await fs.ensureDir(path.join(tmpDir, 'ios')); - await fs.ensureDir(path.join(tmpDir, 'android')); - await fs.writeFile( + await ensureDir(path.join(tmpDir, 'ios')); + await ensureDir(path.join(tmpDir, 'android')); + await fs.promises.writeFile( path.join(tmpDir, 'ios', 'sentry.properties'), 'defaults.org=test\n', ); - await fs.writeFile( + await fs.promises.writeFile( path.join(tmpDir, 'android', 'sentry.properties'), 'defaults.org=test\n', ); diff --git a/tests/zip-entries.test.ts b/tests/zip-entries.test.ts index 5c86fee..bd819b4 100644 --- a/tests/zip-entries.test.ts +++ b/tests/zip-entries.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { ZipFile } from 'yazl'; +import { removeSync } from '../src/utils/fs-helpers'; import { enumZipEntries } from '../src/utils/zip-entries'; function zipBuffer(entries: Record): Promise { @@ -38,7 +39,7 @@ describe('enumZipEntries', () => { ); }); afterEach(() => { - fs.removeSync(dir); + removeSync(dir); }); test('walks into a nested .hap and cleans up its temp dir', async () => { diff --git a/tests/zip-range.test.ts b/tests/zip-range.test.ts index 45dd5b3..49d1650 100644 --- a/tests/zip-range.test.ts +++ b/tests/zip-range.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'fs-extra'; +import fs from 'fs'; import os from 'os'; import path from 'path'; import { fromRandomAccessReader, type ZipFile as YauzlZipFile } from 'yauzl'; import { ZipFile } from 'yazl'; import { deflateRawSync, inflateRawSync } from 'zlib'; +import { removeSync } from '../src/utils/fs-helpers'; import { BundleHashMismatchError, bundleEntryMatcher, @@ -186,7 +187,7 @@ describe('zip-range', () => { }); }); afterEach(() => { - fs.removeSync(dir); + removeSync(dir); }); test('locateZipEntry points at the compressed bytes of the entry', async () => { @@ -620,7 +621,7 @@ describe('zip-range request economy', () => { await writeZip(path.join(dir, 'many.apk'), many); }); afterEach(() => { - fs.removeSync(dir); + removeSync(dir); }); test('parseEndOfCentralDirectory finds the directory from the tail', () => { @@ -883,7 +884,7 @@ describe('zip-range consistency and deadlines', () => { }); }); afterEach(() => { - fs.removeSync(dir); + removeSync(dir); }); const readBundle = async (url: string) => { From 4fab940b6594da3190428d0eae6292ad691edc22 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 11:16:48 +0800 Subject: [PATCH 4/5] refactor(upload): stream uploads over node:http, drop node-fetch and form-data The multipart POST and presigned PUT uploads now go through a small http(s).request wrapper (src/utils/upload-request.ts) with the same proxy agent, exact Content-Length framing and abort/retry behavior. The request line, headers and multipart body are byte-identical to what node-fetch + form-data sent (checked on Node 18, Node 26 and Bun), including form-data's per-extension file Content-Type; only User-Agent now names the CLI instead of node-fetch. The Node < 18 node-fetch fallback in webFetch is gone (engines already require >= 18.17). Co-Authored-By: Claude Opus 5.5 --- bun.lock | 25 ----- package.json | 3 - scripts/smoke-lib.js | 2 +- src/api.ts | 59 ++++-------- src/utils/runtime.ts | 12 +-- src/utils/upload-request.ts | 130 ++++++++++++++++++++++++++ tests/upload-chunked.test.ts | 27 +++--- tests/upload-request.test.ts | 173 +++++++++++++++++++++++++++++++++++ 8 files changed, 339 insertions(+), 92 deletions(-) create mode 100644 src/utils/upload-request.ts create mode 100644 tests/upload-request.test.ts diff --git a/bun.lock b/bun.lock index 0722a56..4619b9a 100644 --- a/bun.lock +++ b/bun.lock @@ -11,12 +11,10 @@ "cli-arguments": "^0.2.1", "compare-versions": "^6.1.1", "filesize-parser": "^1.5.2", - "form-data": "^4.0.6", "global-dirs": "^4.0.0", "gradle-to-js": "^2.0.1", "https-proxy-agent": "^7.0.6", "i18next": "^26.4.0", - "node-fetch": "^2.6.1", "plist": "^5.0.0", "progress": "^2.0.3", "properties": "^1.2.1", @@ -35,7 +33,6 @@ "@types/bun": "^1.4.0", "@types/filesize-parser": "^1.5.3", "@types/node": "^26.3.0", - "@types/node-fetch": "^2.6.13", "@types/progress": "^2.0.7", "@types/yauzl": "^3.4.0", "@types/yazl": "^3.3.1", @@ -89,8 +86,6 @@ "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], - "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - "@types/progress": ["@types/progress@2.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w=="], "@types/yauzl": ["@types/yauzl@3.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw=="], @@ -153,8 +148,6 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -195,8 +188,6 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], @@ -227,8 +218,6 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -257,8 +246,6 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], @@ -369,18 +356,12 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "mixme": ["mixme@0.5.10", "", {}, "sha512-5H76ANWinB1H3twpJ6JY8uvAtpmFvHNArpilJAjXRKXSDDLPIMoZArw5SH0q9z+lLs8IrMw7Q2VWpWimFKFT1Q=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "node-hdiffpatch": ["node-hdiffpatch@2.4.0", "", { "dependencies": { "node-gyp-build": "^4.8.4" }, "bin": { "hdp": "bin/hdiffpatch.js" } }, "sha512-ME9vcelV70aDD5wJw6n5O+VH9llw7v8dn0bgdioKqakhJb6M9K/drfb1+Ub1SrGXsK9TEaitma1PORzCklmt6A=="], @@ -475,8 +456,6 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "tty-table": ["tty-table@5.0.0", "", { "dependencies": { "chalk": "^4.1.2", "csv": "^5.5.3", "kleur": "^4.1.5", "smartwrap": "^2.0.2", "strip-ansi": "^6.0.1", "wcwidth": "^1.0.1", "yargs": "^17.7.1" }, "bin": { "tty-table": "adapters/terminal-adapter.js" } }, "sha512-WhUmYKqKIGu0pGNAHxSE6hYEatb274MdmynDsNVIpRjfK/aT47cVuWTFkB09wTnZfIY9h4NcjYMvWD0VHzJkjQ=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -497,10 +476,6 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], diff --git a/package.json b/package.json index 1c5162c..cc31374 100644 --- a/package.json +++ b/package.json @@ -67,12 +67,10 @@ "cli-arguments": "^0.2.1", "compare-versions": "^6.1.1", "filesize-parser": "^1.5.2", - "form-data": "^4.0.6", "global-dirs": "^4.0.0", "gradle-to-js": "^2.0.1", "https-proxy-agent": "^7.0.6", "i18next": "^26.4.0", - "node-fetch": "^2.6.1", "plist": "^5.0.0", "progress": "^2.0.3", "properties": "^1.2.1", @@ -97,7 +95,6 @@ "@types/bun": "^1.4.0", "@types/filesize-parser": "^1.5.3", "@types/node": "^26.3.0", - "@types/node-fetch": "^2.6.13", "@types/progress": "^2.0.7", "@types/yauzl": "^3.4.0", "@types/yazl": "^3.3.1", diff --git a/scripts/smoke-lib.js b/scripts/smoke-lib.js index 6fdc497..d26e5d4 100644 --- a/scripts/smoke-lib.js +++ b/scripts/smoke-lib.js @@ -248,7 +248,7 @@ async function proxyScenario() { }); }); // tunnels to the mock host: the API client (undici), the registry check - // (http.get) and the upload (node-fetch) each open their own + // (http.get) and the upload (http.request) each open their own const tunnels = () => seenByProxy.filter((line) => line === `CONNECT 127.0.0.1:${apiPort}`) .length; diff --git a/src/api.ts b/src/api.ts index 8bba1da..7ede8dc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,9 +1,5 @@ import filesizeParser from 'filesize-parser'; import fs from 'fs'; -import type { - RequestInit as NodeFetchRequestInit, - Response as NodeFetchResponse, -} from 'node-fetch'; import path from 'path'; import type ProgressBar from 'progress'; import packageJson from '../package.json'; @@ -20,6 +16,12 @@ import { resolveProxy, runtimeFetch, } from './utils/runtime'; +import { + multipartRequest, + sendUploadRequest, + type UploadRequest, + type UploadResponse, +} from './utils/upload-request'; let session: Session | undefined; let savedSession: Session | undefined; @@ -296,17 +298,6 @@ class UploadTimeoutError extends Error { } } -/** - * node-fetch is only needed for the streaming multipart / PUT upload below - * (the built-in fetch cannot stream a form-data body). Loading it eagerly - * pulled whatwg-url → punycode into every command, which Node ≥ 21 greets - * with a DEP0040 deprecation warning on stderr; so it is loaded on first use. - */ -function loadNodeFetch(): typeof import('node-fetch').default { - const mod = require('node-fetch'); - return (mod.default ?? mod) as typeof import('node-fetch').default; -} - /** A byte range of the file (end exclusive) and its retry budget. */ interface UploadSlice { start: number; @@ -324,11 +315,10 @@ async function sendUpload( realUrl: string, fileSize: number, bar: ProgressBar, - buildRequest: (fileStream: fs.ReadStream) => NodeFetchRequestInit, + buildRequest: (fileStream: fs.ReadStream) => UploadRequest, slice: UploadSlice = { start: 0, end: fileSize, retries: UPLOAD_MAX_RETRIES }, -): Promise { +): Promise { const timeoutMs = uploadTimeoutMs(slice.end - slice.start); - const nodeFetch = loadNodeFetch(); // HTTP(S)_PROXY / NO_PROXY, like every other request of the CLI const agent = proxyAgentFor(realUrl); for (let attempt = 0; ; attempt++) { @@ -349,10 +339,10 @@ async function sendUpload( bar.tick(data.length); }); try { - return await nodeFetch(realUrl, { - ...buildRequest(fileStream), + return await sendUploadRequest(realUrl, buildRequest(fileStream), { agent, signal: controller.signal, + userAgent, }); } catch (rawError) { fileStream.destroy(); @@ -399,7 +389,7 @@ async function sendChunks( postForm: ( fields: Record, byteCount: number, - ) => (fileStream: fs.ReadStream) => NodeFetchRequestInit, + ) => (fileStream: fs.ReadStream) => UploadRequest, ): Promise { let next = 0; let failure: unknown; @@ -483,7 +473,7 @@ export async function uploadFile( ); } - // progress/form-data are only needed here; keep them off the startup path + // progress is only needed here; keep it off the startup path const progressModule = require('progress'); const ProgressBarImpl = (progressModule.default ?? progressModule) as typeof import('progress'); @@ -506,7 +496,7 @@ export async function uploadFile( // 自托管节点的 s3 直传:服务端下发预签名 PUT,字节直达用户的对象存储 if (resp.method === 'PUT') { - let putRes: NodeFetchResponse; + let putRes: UploadResponse; try { putRes = await sendUpload(fn, realUrl, fileSize, bar, (fileStream) => ({ method: 'PUT', @@ -529,23 +519,14 @@ export async function uploadFile( return { hash: resp.key }; } - const formDataModule = require('form-data'); - const FormData = (formDataModule.default ?? - formDataModule) as typeof import('form-data'); const postForm = (fields: Record, byteCount: number) => - (fileStream: fs.ReadStream): NodeFetchRequestInit => { - const form = new FormData(); - for (const [k, v] of Object.entries(fields)) { - form.append(k, v); - } - // With every part's length known node-fetch sends Content-Length - // instead of a chunked body: what object stores expect, and the only - // framing that survives http-proxy-agent's rewrite of the buffered - // request head. - form.append('file', fileStream, { knownLength: byteCount }); - return { method: 'POST', body: form }; - }; + (fileStream: fs.ReadStream): UploadRequest => + multipartRequest(fields, { + name: fn, + size: byteCount, + stream: fileStream, + }); if (resp.chunked && !key) { try { @@ -563,7 +544,7 @@ export async function uploadFile( return { hash: resp.chunked.key as string }; } - let res: NodeFetchResponse; + let res: UploadResponse; try { res = await sendUpload( fn, diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts index 04b85a5..bb5131a 100644 --- a/src/utils/runtime.ts +++ b/src/utils/runtime.ts @@ -147,22 +147,14 @@ export function webFetch( dispatcher, }) as unknown as Promise; } - if (typeof globalThis.fetch === 'function') { - return globalThis.fetch(url, init); - } - // Node < 18 only. Loaded on demand so the built-in fetch path never pays - // for node-fetch (nor for the punycode deprecation warning it triggers on - // Node ≥ 21). - const mod = require('node-fetch'); - const nodeFetch = (mod.default ?? mod) as typeof import('node-fetch').default; - return nodeFetch(url, init as any) as unknown as Promise; + return globalThis.fetch(url, init); } const proxyAgents = new Map(); /** * An http.Agent routing `targetUrl` through its proxy, for the core - * http/https APIs and node-fetch (the streaming uploads); undefined when the + * http/https APIs (including the streaming uploads); undefined when the * connection is direct. Always a CONNECT tunnel (also for http targets, like * undici's ProxyAgent above): the absolute-form alternative rewrites the * request head after it was buffered, which garbles a streaming body. diff --git a/src/utils/upload-request.ts b/src/utils/upload-request.ts new file mode 100644 index 0000000..b0edd18 --- /dev/null +++ b/src/utils/upload-request.ts @@ -0,0 +1,130 @@ +// Streaming uploads on node:http(s): a multipart form or a raw PUT whose body +// is read from disk as it is sent, with an exact Content-Length. + +import { randomBytes } from 'crypto'; +import type { Agent as HttpAgent } from 'http'; +import path from 'path'; +import { Readable } from 'stream'; + +export interface UploadRequest { + method: string; + headers: Record; + body: Readable; +} + +export interface UploadResponse { + status: number; + statusText: string; +} + +/** + * The file part's Content-Type for the extensions the CLI uploads, as the + * form-data package used to pick them (mime-types): object stores keep it as + * the stored object's type. + */ +const FILE_CONTENT_TYPES: Record = { + '.aab': 'application/x-authorware-bin', + '.apk': 'application/vnd.android.package-archive', + '.json': 'application/json', + '.map': 'application/json', + '.zip': 'application/zip', +}; + +export function fileContentType(fileName: string): string { + return ( + FILE_CONTENT_TYPES[path.extname(fileName).toLowerCase()] ?? + 'application/octet-stream' + ); +} + +/** + * multipart/form-data with `fields` first and the file last. The file's size + * is known up front, so the request carries Content-Length instead of a + * chunked body: what object stores expect, and the only framing that survives + * a proxy agent's rewrite of the buffered request head. + */ +export function multipartRequest( + fields: Record, + file: { name: string; size: number; stream: Readable }, +): UploadRequest { + // A 'data' listener (the progress bar) may have set the stream flowing: + // hold its chunks until the body below reads them. + file.stream.pause(); + const boundary = `--------------------------${randomBytes(12).toString('hex')}`; + let head = ''; + for (const [name, value] of Object.entries(fields)) { + head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`; + } + head += + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${path.basename(file.name)}"\r\n` + + `Content-Type: ${fileContentType(file.name)}\r\n\r\n`; + const headBytes = Buffer.from(head); + const tailBytes = Buffer.from(`\r\n--${boundary}--\r\n`); + async function* parts() { + yield headBytes; + for await (const chunk of file.stream) yield chunk as Buffer; + yield tailBytes; + } + return { + method: 'POST', + headers: { + 'content-type': `multipart/form-data;boundary=${boundary}`, + 'content-length': String(headBytes.length + file.size + tailBytes.length), + }, + body: Readable.from(parts(), { objectMode: false }), + }; +} + +/** + * Send `request` and resolve with the response status once its head arrives + * (the body is drained and dropped). Network errors reject as they are, with + * their `code` (ECONNRESET, ...); aborting `signal` rejects with an AbortError. + */ +export function sendUploadRequest( + url: string, + request: UploadRequest, + options: { agent?: HttpAgent; signal?: AbortSignal; userAgent?: string }, +): Promise { + const { request: send } = url.startsWith('https:') + ? (require('https') as typeof import('https')) + : (require('http') as typeof import('http')); + return new Promise((resolve, reject) => { + const req = send(url, { + method: request.method, + headers: { + accept: '*/*', + ...(options.userAgent ? { 'user-agent': options.userAgent } : {}), + ...request.headers, + }, + agent: options.agent, + }); + const fail = (error: unknown) => { + request.body.destroy(); + req.destroy(error as Error); + reject(error); + }; + const { signal } = options; + const onAbort = () => { + fail( + Object.assign(new Error('The upload was aborted'), { + name: 'AbortError', + code: 'ABORT_ERR', + }), + ); + }; + if (signal?.aborted) return onAbort(); + signal?.addEventListener('abort', onAbort, { once: true }); + req.on('error', fail); + request.body.on('error', fail); + req.on('response', (res) => { + signal?.removeEventListener('abort', onAbort); + res.resume(); + resolve({ + status: res.statusCode ?? 0, + statusText: res.statusMessage ?? '', + }); + }); + request.body.pipe(req); + }); +} diff --git a/tests/upload-chunked.test.ts b/tests/upload-chunked.test.ts index de94731..0431aed 100644 --- a/tests/upload-chunked.test.ts +++ b/tests/upload-chunked.test.ts @@ -1,8 +1,7 @@ -import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import { PassThrough } from 'stream'; type FetchCall = { url: string; body: Buffer }; const fetchCalls: FetchCall[] = []; @@ -13,23 +12,13 @@ let fetchImpl: (call: FetchCall) => Promise<{ status: number }> = async () => ({ // Read the streamed multipart body the way the socket would. async function drain(body: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; - // form-data is an old-style stream: pipe it into a modern one - const pass = new PassThrough(); - body.pipe(pass); - for await (const chunk of pass) { + for await (const chunk of body) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); } -mock.module('node-fetch', () => ({ - default: async (url: string, init: { body: NodeJS.ReadableStream }) => { - const call = { url, body: await drain(init.body) }; - fetchCalls.push(call); - const response = await fetchImpl(call); - return { statusText: '', ...response }; - }, -})); +const uploadRequest = await import('../src/utils/upload-request'); const { uploadFile } = await import('../src/api'); const runtime = await import('../src/utils/runtime'); const httpHelper = await import('../src/utils/http-helper'); @@ -81,6 +70,16 @@ describe('chunked upload', () => { }; }, ), + spyOn(uploadRequest, 'sendUploadRequest').mockImplementation( + async (url, request) => { + const call = { url, body: await drain(request.body) }; + expect(Number(request.headers['content-length'])).toBe( + call.body.length, + ); + fetchCalls.push(call); + return { statusText: '', ...(await fetchImpl(call)) }; + }, + ), spyOn(runtime, 'measureTcpLatency').mockResolvedValue(10), // the base url is memoized per process: another test file may have // resolved it already, so pin it instead of setting RNU_API diff --git a/tests/upload-request.test.ts b/tests/upload-request.test.ts new file mode 100644 index 0000000..71a2434 --- /dev/null +++ b/tests/upload-request.test.ts @@ -0,0 +1,173 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { randomBytes } from 'crypto'; +import fs from 'fs'; +import http from 'http'; +import type { AddressInfo } from 'net'; +import os from 'os'; +import path from 'path'; +import { + fileContentType, + multipartRequest, + sendUploadRequest, +} from '../src/utils/upload-request'; + +type Received = { + method?: string; + headers: http.IncomingHttpHeaders; + body: Buffer; +}; + +let dir: string; +let server: http.Server; +let base: string; +const received: Received[] = []; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-upload-request-')); + server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + received.push({ + method: req.method, + headers: req.headers, + body: Buffer.concat(chunks), + }); + if (req.url === '/hang') return; + res.writeHead(req.url === '/denied' ? 403 : 204); + res.end(); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => { + server.closeAllConnections(); + server.close(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function writeFile(name: string, data: Buffer) { + const file = path.join(dir, name); + fs.writeFileSync(file, data); + return file; +} + +describe('upload-request', () => { + test('file content types match what form-data used to send', () => { + expect(fileContentType('a.apk')).toBe( + 'application/vnd.android.package-archive', + ); + expect(fileContentType('a.aab')).toBe('application/x-authorware-bin'); + expect(fileContentType('a.map')).toBe('application/json'); + expect(fileContentType('/x/a.ppk')).toBe('application/octet-stream'); + expect(fileContentType('a.ipa')).toBe('application/octet-stream'); + expect(fileContentType('a.app')).toBe('application/octet-stream'); + }); + + test('posts a multipart form with an exact Content-Length', async () => { + const data = randomBytes(3 << 20); + const file = writeFile('bundle.ppk', data); + const stream = fs.createReadStream(file); + let progress = 0; + stream.on('data', (chunk) => { + progress += chunk.length; + }); + received.length = 0; + const res = await sendUploadRequest( + `${base}/ok`, + multipartRequest( + { key: 'k/1', policy: 'p' }, + { name: file, size: data.length, stream }, + ), + { userAgent: 'rnu-test' }, + ); + expect(res).toEqual({ status: 204, statusText: 'No Content' }); + expect(progress).toBe(data.length); + + const [{ method, headers, body }] = received; + expect(method).toBe('POST'); + expect(headers['user-agent']).toBe('rnu-test'); + expect(Number(headers['content-length'])).toBe(body.length); + expect(headers['transfer-encoding']).toBeUndefined(); + const boundary = /^multipart\/form-data;boundary=(-+[0-9a-f]{24})$/.exec( + headers['content-type'] ?? '', + )?.[1]; + expect(boundary).toBeDefined(); + const head = + `--${boundary}\r\nContent-Disposition: form-data; name="key"\r\n\r\nk/1\r\n` + + `--${boundary}\r\nContent-Disposition: form-data; name="policy"\r\n\r\np\r\n` + + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="bundle.ppk"\r\n` + + 'Content-Type: application/octet-stream\r\n\r\n'; + expect(body).toEqual( + Buffer.concat([ + Buffer.from(head), + data, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]), + ); + }); + + test('sends a raw PUT body with the given headers', async () => { + const data = randomBytes(1000); + const file = writeFile('package.apk', data); + received.length = 0; + const res = await sendUploadRequest( + `${base}/ok`, + { + method: 'PUT', + headers: { 'content-length': '1000', 'x-amz-acl': 'private' }, + body: fs.createReadStream(file), + }, + {}, + ); + expect(res.status).toBe(204); + expect(received[0].method).toBe('PUT'); + expect(received[0].headers['x-amz-acl']).toBe('private'); + expect(received[0].body).toEqual(data); + }); + + test('resolves with an HTTP error status instead of rejecting', async () => { + const file = writeFile('denied.ppk', Buffer.from('x')); + const res = await sendUploadRequest( + `${base}/denied`, + multipartRequest( + {}, + { name: file, size: 1, stream: fs.createReadStream(file) }, + ), + {}, + ); + expect(res).toEqual({ status: 403, statusText: 'Forbidden' }); + }); + + test('aborting rejects with an AbortError', async () => { + const file = writeFile('hang.ppk', Buffer.from('x')); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + await expect( + sendUploadRequest( + `${base}/hang`, + multipartRequest( + {}, + { name: file, size: 1, stream: fs.createReadStream(file) }, + ), + { signal: controller.signal }, + ), + ).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }); + }); + + test('a refused connection rejects with its error code', async () => { + const file = writeFile('refused.ppk', Buffer.from('x')); + await expect( + sendUploadRequest( + 'http://127.0.0.1:1/', + multipartRequest( + {}, + { name: file, size: 1, stream: fs.createReadStream(file) }, + ), + {}, + ), + ).rejects.toMatchObject({ code: 'ECONNREFUSED' }); + }); +}); From fbdd8c2f5051268c58534ea70ffedc15279c270f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 11:23:51 +0800 Subject: [PATCH 5/5] fix: address review on emptyDirSync, upload size and dead catch blocks - emptyDirSync creates the directory only when it is missing (ENOENT); an unreadable existing one now throws instead of passing for emptied. - A multipart upload whose file streams fewer bytes than its declared size fails right away instead of waiting out the upload deadline. - resource-finder: drop try/catch blocks around Buffer#toString, which never throws. Co-Authored-By: Claude Opus 5.5 --- src/utils/app-info-parser/resource-finder.ts | 21 ++------------------ src/utils/fs-helpers.ts | 5 ++++- src/utils/upload-request.ts | 13 +++++++++++- tests/fs-helpers.test.ts | 15 ++++++++++++++ tests/upload-request.test.ts | 14 +++++++++++++ 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/utils/app-info-parser/resource-finder.ts b/src/utils/app-info-parser/resource-finder.ts index 0597c49..a9b9a05 100644 --- a/src/utils/app-info-parser/resource-finder.ts +++ b/src/utils/app-info-parser/resource-finder.ts @@ -453,15 +453,7 @@ export class ResourceFinder { } if (u8len > 0) { - const buffer = bb.readBytes(u8len); - try { - strings[i] = buffer.buffer.toString('utf8'); - } catch (e) { - if (DEBUG) { - console.error(e); - console.log('Error when turning buffer to utf-8 string.'); - } - } + strings[i] = bb.readBytes(u8len).buffer.toString('utf8'); } else { strings[i] = ''; } @@ -472,16 +464,7 @@ export class ResourceFinder { } if (u16len > 0) { - const len = u16len * 2; - const buffer = bb.readBytes(len); - try { - strings[i] = buffer.buffer.toString('utf16le'); - } catch (e) { - if (DEBUG) { - console.error(e); - console.log('Error when turning buffer to utf-16 string.'); - } - } + strings[i] = bb.readBytes(u16len * 2).buffer.toString('utf16le'); } } diff --git a/src/utils/fs-helpers.ts b/src/utils/fs-helpers.ts index 7d2d292..52053d3 100644 --- a/src/utils/fs-helpers.ts +++ b/src/utils/fs-helpers.ts @@ -32,7 +32,10 @@ export function emptyDirSync(dir: string): void { let names: string[]; try { names = fs.readdirSync(dir); - } catch { + } catch (err) { + // only a missing directory is created; an unreadable one must not pass + // for emptied + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; ensureDirSync(dir); return; } diff --git a/src/utils/upload-request.ts b/src/utils/upload-request.ts index b0edd18..30b25fc 100644 --- a/src/utils/upload-request.ts +++ b/src/utils/upload-request.ts @@ -63,7 +63,18 @@ export function multipartRequest( const tailBytes = Buffer.from(`\r\n--${boundary}--\r\n`); async function* parts() { yield headBytes; - for await (const chunk of file.stream) yield chunk as Buffer; + let sent = 0; + for await (const chunk of file.stream) { + sent += (chunk as Buffer).length; + yield chunk as Buffer; + } + // a file that shrank since its size was taken would leave the request + // short of its Content-Length, waiting out the whole upload deadline + if (sent !== file.size) { + throw new Error( + `${file.name} changed during the upload: sent ${sent} of ${file.size} bytes`, + ); + } yield tailBytes; } return { diff --git a/tests/fs-helpers.test.ts b/tests/fs-helpers.test.ts index 15a1c51..325e843 100644 --- a/tests/fs-helpers.test.ts +++ b/tests/fs-helpers.test.ts @@ -56,6 +56,21 @@ describe('fs-helpers', () => { expect(fs.readdirSync(path.join(dir, 'new/deep'))).toEqual([]); }); + test('emptyDirSync rethrows when an existing directory cannot be read', () => { + const locked = path.join(dir, 'locked'); + ensureDirSync(locked); + fs.writeFileSync(path.join(locked, 'stale.js'), 'x'); + fs.chmodSync(locked, 0o000); + try { + // root reads anything: nothing to assert there + if (process.getuid?.() === 0) return; + expect(() => emptyDirSync(locked)).toThrow(); + } finally { + fs.chmodSync(locked, 0o755); + } + expect(fs.readdirSync(locked)).toEqual(['stale.js']); + }); + test('moveSync replaces the destination and creates its parent', () => { fs.writeFileSync(path.join(dir, 'src.js'), 'new'); moveSync(path.join(dir, 'src.js'), path.join(dir, 'a/b/dest.js')); diff --git a/tests/upload-request.test.ts b/tests/upload-request.test.ts index 71a2434..75a54e6 100644 --- a/tests/upload-request.test.ts +++ b/tests/upload-request.test.ts @@ -141,6 +141,20 @@ describe('upload-request', () => { expect(res).toEqual({ status: 403, statusText: 'Forbidden' }); }); + test('a file shorter than its declared size fails instead of hanging', async () => { + const file = writeFile('shrunk.ppk', Buffer.from('abc')); + await expect( + sendUploadRequest( + `${base}/ok`, + multipartRequest( + {}, + { name: file, size: 10, stream: fs.createReadStream(file) }, + ), + {}, + ), + ).rejects.toThrow('changed during the upload: sent 3 of 10 bytes'); + }); + test('aborting rejects with an AbortError', async () => { const file = writeFile('hang.ppk', Buffer.from('x')); const controller = new AbortController();