Skip to content

Commit 3a3f14f

Browse files
committed
perf: lazy-load heavy external imports across modules
Defer loading of heavy external sub-bundles until first use via lazy require() getters with /*@__PURE__*/ and /*@__NO_SIDE_EFFECTS__*/ annotations for tree-shaking. sorts.ts: semver (2.5 MB via npm-pack) and fastSort (8 KB) versions.ts: semver (2.5 MB via npm-pack) archives.ts: adm-zip (102 KB) and tar-fs (105 KB) globs.ts: fast-glob and picomatch (260 KB via pico-pack) fs.ts: del (260 KB via pico-pack) spawn.ts: @npmcli/promise-spawn (17 KB) strings.ts: get-east-asian-width (10 KB) Impact: consumers importing lightweight exports (isObject, httpJson, localeCompare, defaultIgnore, readJsonSync, stripAnsi, spawnSync) no longer pay for heavy externals at module init time. Total deferred: ~494 KB unique, ~2.8 MB including npm-pack chain.
1 parent ab2e353 commit 3a3f14f

File tree

7 files changed

+153
-51
lines changed

7 files changed

+153
-51
lines changed

src/archives.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,26 @@ import { pipeline } from 'node:stream/promises'
88
import { createGunzip } from 'node:zlib'
99
import process from 'node:process'
1010

11-
import AdmZip from './external/adm-zip.js'
12-
import tarFs from './external/tar-fs.js'
11+
import type AdmZipType from './external/adm-zip.js'
12+
import type tarFsType from './external/tar-fs.js'
13+
14+
let _AdmZip: typeof AdmZipType | undefined
15+
/*@__NO_SIDE_EFFECTS__*/
16+
function getAdmZip() {
17+
if (_AdmZip === undefined) {
18+
_AdmZip = /*@__PURE__*/ require('./external/adm-zip.js')
19+
}
20+
return _AdmZip!
21+
}
22+
23+
let _tarFs: typeof tarFsType | undefined
24+
/*@__NO_SIDE_EFFECTS__*/
25+
function getTarFs() {
26+
if (_tarFs === undefined) {
27+
_tarFs = /*@__PURE__*/ require('./external/tar-fs.js')
28+
}
29+
return _tarFs!
30+
}
1331

1432
import { safeMkdir } from './fs.js'
1533
import { normalizePath } from './paths/normalize.js'
@@ -142,7 +160,7 @@ export async function extractTar(
142160

143161
let destroyScheduled = false
144162

145-
const extractStream = tarFs.extract(normalizedOutputDir, {
163+
const extractStream = getTarFs().extract(normalizedOutputDir, {
146164
map: (header: { name: string; size?: number; type?: string }) => {
147165
// Skip if destroy already scheduled
148166
if (destroyScheduled) {
@@ -267,7 +285,7 @@ export async function extractTarGz(
267285

268286
let destroyScheduled = false
269287

270-
const extractStream = tarFs.extract(normalizedOutputDir, {
288+
const extractStream = getTarFs().extract(normalizedOutputDir, {
271289
map: (header: { name: string; size?: number; type?: string }) => {
272290
// Skip if destroy already scheduled
273291
if (destroyScheduled) {
@@ -387,7 +405,7 @@ export async function extractZip(
387405
const normalizedOutputDir = normalizePath(outputDir)
388406
await safeMkdir(normalizedOutputDir)
389407

390-
const zip = new AdmZip(archivePath)
408+
const zip = new (getAdmZip())(archivePath)
391409
const path = getPath()
392410

393411
// Pre-validate all entries for security

src/fs.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,21 @@ import type {
1919
import { getAbortSignal } from './constants/process'
2020

2121
import { isArray } from './arrays'
22-
import { deleteAsync, deleteSync } from './external/del'
22+
import type {
23+
deleteAsync as deleteAsyncType,
24+
deleteSync as deleteSyncType,
25+
} from './external/del'
26+
27+
let _del:
28+
| { deleteAsync: typeof deleteAsyncType; deleteSync: typeof deleteSyncType }
29+
| undefined
30+
/*@__NO_SIDE_EFFECTS__*/
31+
function getDel() {
32+
if (_del === undefined) {
33+
_del = /*@__PURE__*/ require('./external/del')
34+
}
35+
return _del!
36+
}
2337
import { pRetry } from './promises'
2438
import { defaultIgnore, getGlobMatcher } from './globs'
2539
import type { JsonReviver } from './json/types'
@@ -1235,7 +1249,7 @@ export async function safeDelete(
12351249
filepath: PathLike | PathLike[],
12361250
options?: RemoveOptions | undefined,
12371251
) {
1238-
// deleteAsync is imported at the top
1252+
// deleteAsync is lazily loaded via getDel()
12391253
const opts = { __proto__: null, ...options } as RemoveOptions
12401254
const patterns = isArray(filepath)
12411255
? filepath.map(pathLikeToString)
@@ -1278,7 +1292,7 @@ export async function safeDelete(
12781292
/* c8 ignore start - External del call */
12791293
await pRetry(
12801294
async () => {
1281-
await deleteAsync(patterns, {
1295+
await getDel().deleteAsync(patterns, {
12821296
dryRun: false,
12831297
force: shouldForce,
12841298
onlyFiles: false,
@@ -1328,7 +1342,7 @@ export function safeDeleteSync(
13281342
filepath: PathLike | PathLike[],
13291343
options?: RemoveOptions | undefined,
13301344
) {
1331-
// deleteSync is imported at the top
1345+
// deleteSync is lazily loaded via getDel()
13321346
const opts = { __proto__: null, ...options } as RemoveOptions
13331347
const patterns = isArray(filepath)
13341348
? filepath.map(pathLikeToString)
@@ -1373,7 +1387,7 @@ export function safeDeleteSync(
13731387
let delay = retryDelay
13741388
for (let attempt = 0; attempt <= maxRetries; attempt++) {
13751389
try {
1376-
deleteSync(patterns, {
1390+
getDel().deleteSync(patterns, {
13771391
dryRun: false,
13781392
force: shouldForce,
13791393
onlyFiles: false,

src/globs.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,26 @@
33
* Provides file filtering and glob matcher functions for npm-like behavior.
44
*/
55

6-
// IMPORTANT: Do not use destructuring here - use direct assignment instead.
7-
import * as fastGlob from './external/fast-glob.js'
8-
import picomatch from './external/picomatch.js'
6+
import type * as fastGlobType from './external/fast-glob.js'
7+
import type picomatchType from './external/picomatch.js'
8+
9+
let _fastGlob: typeof fastGlobType | undefined
10+
/*@__NO_SIDE_EFFECTS__*/
11+
function getFastGlob() {
12+
if (_fastGlob === undefined) {
13+
_fastGlob = /*@__PURE__*/ require('./external/fast-glob.js')
14+
}
15+
return _fastGlob!
16+
}
17+
18+
let _picomatch: typeof picomatchType | undefined
19+
/*@__NO_SIDE_EFFECTS__*/
20+
function getPicomatch() {
21+
if (_picomatch === undefined) {
22+
_picomatch = /*@__PURE__*/ require('./external/picomatch.js')
23+
}
24+
return _picomatch!
25+
}
926

1027
import { objectFreeze as ObjectFreeze } from './objects'
1128
import {
@@ -112,7 +129,7 @@ export function globStreamLicenses(
112129
ignore.push(LICENSE_ORIGINAL_GLOB_RECURSIVE)
113130
}
114131
/* c8 ignore start - External fast-glob call */
115-
return fastGlob.globStream(
132+
return getFastGlob().globStream(
116133
[recursive ? LICENSE_GLOB_RECURSIVE : LICENSE_GLOB],
117134
{
118135
__proto__: null,
@@ -189,7 +206,7 @@ export function getGlobMatcher(
189206
}
190207

191208
/* c8 ignore next 4 - External picomatch call */
192-
matcher = picomatch(
209+
matcher = getPicomatch()(
193210
positivePatterns.length > 0 ? positivePatterns : patterns,
194211
matchOptions,
195212
) as (path: string) => boolean
@@ -209,7 +226,7 @@ export function glob(
209226
options?: FastGlobOptions,
210227
): Promise<string[]> {
211228
/* c8 ignore next - External fast-glob call */
212-
return fastGlob.glob(patterns, options as import('fast-glob').Options)
229+
return getFastGlob().glob(patterns, options as import('fast-glob').Options)
213230
}
214231

215232
/**
@@ -222,5 +239,8 @@ export function globSync(
222239
options?: FastGlobOptions,
223240
): string[] {
224241
/* c8 ignore next - External fast-glob call */
225-
return fastGlob.globSync(patterns, options as import('fast-glob').Options)
242+
return getFastGlob().globSync(
243+
patterns,
244+
options as import('fast-glob').Options,
245+
)
226246
}

src/sorts.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,34 @@
33
* Provides various comparison utilities for arrays and collections.
44
*/
55

6-
import * as fastSort from './external/fast-sort.js'
7-
import * as semver from './external/semver.js'
6+
import type * as fastSortType from './external/fast-sort.js'
7+
import type * as semverType from './external/semver.js'
8+
9+
let _semver: typeof semverType | undefined
10+
function getSemver() {
11+
if (_semver === undefined) {
12+
_semver = require('./external/semver.js')
13+
}
14+
return _semver!
15+
}
16+
17+
let _fastSort: typeof fastSortType | undefined
18+
function getFastSort() {
19+
if (_fastSort === undefined) {
20+
_fastSort = require('./external/fast-sort.js')
21+
}
22+
return _fastSort!
23+
}
824

925
/**
1026
* Compare semantic versions.
1127
*/
1228
/*@__NO_SIDE_EFFECTS__*/
1329
export function compareSemver(a: string, b: string): number {
1430
/* c8 ignore next 2 - External semver calls */
15-
const validA: string | null = semver.valid(a)
16-
const validB: string | null = semver.valid(b)
31+
const sv = getSemver()
32+
const validA: string | null = sv.valid(a)
33+
const validB: string | null = sv.valid(b)
1734

1835
if (!validA && !validB) {
1936
return 0
@@ -25,7 +42,7 @@ export function compareSemver(a: string, b: string): number {
2542
return 1
2643
}
2744
/* c8 ignore next - External semver call */
28-
return semver.compare(a, b) as number
45+
return sv.compare(a, b) as number
2946
}
3047

3148
/**
@@ -90,7 +107,7 @@ export function naturalSorter<T>(
90107
): ReturnType<FastSortFunction> {
91108
if (_naturalSorter === undefined) {
92109
/* c8 ignore next 3 - External fast-sort call */
93-
_naturalSorter = fastSort.createNewSortInstance({
110+
_naturalSorter = getFastSort().createNewSortInstance({
94111
comparer: naturalCompare,
95112
}) as FastSortFunction
96113
}

src/spawn.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,16 @@ import process from 'node:process'
3030
import { getAbortSignal } from './constants/process'
3131
import { stackWithCauses } from './errors'
3232

33-
import npmCliPromiseSpawn from './external/@npmcli/promise-spawn'
33+
import type npmCliPromiseSpawnType from './external/@npmcli/promise-spawn'
34+
35+
let _npmCliPromiseSpawn: typeof npmCliPromiseSpawnType | undefined
36+
/*@__NO_SIDE_EFFECTS__*/
37+
function getNpmCliPromiseSpawn() {
38+
if (_npmCliPromiseSpawn === undefined) {
39+
_npmCliPromiseSpawn = /*@__PURE__*/ require('./external/@npmcli/promise-spawn')
40+
}
41+
return _npmCliPromiseSpawn!
42+
}
3443

3544
let _path: typeof import('node:path') | undefined
3645
/**
@@ -738,7 +747,7 @@ export function spawn(
738747
if (shouldStopSpinner) {
739748
spinnerInstance.stop()
740749
}
741-
// npmCliPromiseSpawn is imported at the top
750+
// npmCliPromiseSpawn is lazily loaded via getNpmCliPromiseSpawn()
742751
// Use __proto__: null to prevent prototype pollution when passing to
743752
// third-party code, Node.js built-ins, or JavaScript built-in methods.
744753
// https://github.com/npm/promise-spawn
@@ -769,10 +778,10 @@ export function spawn(
769778
gid: spawnOptions.gid,
770779
} as unknown as PromiseSpawnOptions
771780
/* c8 ignore start - External npmCliPromiseSpawn call */
772-
const spawnPromise = npmCliPromiseSpawn(
781+
const spawnPromise = getNpmCliPromiseSpawn()(
773782
actualCmd,
774783
args ? [...args] : [],
775-
promiseSpawnOpts as Parameters<typeof npmCliPromiseSpawn>[2],
784+
promiseSpawnOpts as Parameters<typeof npmCliPromiseSpawnType>[2],
776785
extra,
777786
)
778787
/* c8 ignore stop */

src/strings.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,20 @@
44
*/
55

66
import { ansiRegex, stripAnsi } from './ansi'
7-
import { eastAsianWidth } from './external/get-east-asian-width'
7+
import type { eastAsianWidth as eastAsianWidthType } from './external/get-east-asian-width'
8+
9+
let _eastAsianWidth: typeof eastAsianWidthType | undefined
10+
/*@__NO_SIDE_EFFECTS__*/
11+
function getEastAsianWidth() {
12+
if (_eastAsianWidth === undefined) {
13+
_eastAsianWidth = /*@__PURE__*/ (
14+
require('./external/get-east-asian-width') as {
15+
eastAsianWidth: typeof eastAsianWidthType
16+
}
17+
).eastAsianWidth
18+
}
19+
return _eastAsianWidth!
20+
}
821
// Import get-east-asian-width from external wrapper.
922
// This library implements Unicode Standard Annex #11 (East Asian Width).
1023
// https://www.unicode.org/reports/tr11/
@@ -736,7 +749,7 @@ export function stringWidth(text: string): number {
736749
// - Halfwidth (1 column): Halfwidth Katakana (ア, イ, ウ)
737750
// - Ambiguous (1 column per our config): Greek, Cyrillic, box drawing
738751
/* c8 ignore next - External eastAsianWidth call */
739-
width += eastAsianWidth(codePoint, eastAsianWidthOptions)
752+
width += getEastAsianWidth()(codePoint, eastAsianWidthOptions)
740753

741754
// STEP 4: Handle trailing Halfwidth and Fullwidth Forms
742755
//
@@ -770,7 +783,10 @@ export function stringWidth(text: string): number {
770783
// Add the East Asian Width of this trailing character.
771784
// Most halfwidth forms contribute 1 column, fullwidth contribute 2.
772785
/* c8 ignore next - External eastAsianWidth call */
773-
width += eastAsianWidth(trailingCodePoint, eastAsianWidthOptions)
786+
width += getEastAsianWidth()(
787+
trailingCodePoint,
788+
eastAsianWidthOptions,
789+
)
774790
}
775791
}
776792
}

0 commit comments

Comments
 (0)