@scelar/nodepod 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{child_process-53fMkug_.js → child_process-4ZrgCVFu.js} +8234 -8233
  2. package/dist/{child_process-53fMkug_.js.map → child_process-4ZrgCVFu.js.map} +1 -1
  3. package/dist/{child_process-lxSKECHq.cjs → child_process-Cao4lyrb.cjs} +7435 -7434
  4. package/dist/{child_process-lxSKECHq.cjs.map → child_process-Cao4lyrb.cjs.map} +1 -1
  5. package/dist/{index-C-TQIrdG.cjs → index-DuYo2yDs.cjs} +38842 -38005
  6. package/dist/index-DuYo2yDs.cjs.map +1 -0
  7. package/dist/{index-B8lyh_ti.js → index-HkVqijtm.js} +36923 -36065
  8. package/dist/index-HkVqijtm.js.map +1 -0
  9. package/dist/index.cjs +67 -65
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.mjs +61 -59
  13. package/dist/memory-handler.d.ts +57 -0
  14. package/dist/memory-volume.d.ts +12 -2
  15. package/dist/packages/installer.d.ts +3 -0
  16. package/dist/persistence/idb-cache.d.ts +7 -0
  17. package/dist/polyfills/readline.d.ts +108 -87
  18. package/dist/script-engine.d.ts +3 -0
  19. package/dist/sdk/nodepod-process.d.ts +2 -1
  20. package/dist/sdk/nodepod.d.ts +20 -1
  21. package/dist/sdk/types.d.ts +5 -0
  22. package/package.json +1 -1
  23. package/src/index.ts +2 -0
  24. package/src/memory-handler.ts +168 -0
  25. package/src/memory-volume.ts +72 -8
  26. package/src/packages/installer.ts +49 -1
  27. package/src/packages/version-resolver.ts +421 -421
  28. package/src/persistence/idb-cache.ts +107 -0
  29. package/src/polyfills/child_process.ts +3 -0
  30. package/src/polyfills/events.ts +22 -4
  31. package/src/polyfills/readline.ts +593 -71
  32. package/src/polyfills/stream.ts +46 -0
  33. package/src/polyfills/wasi.ts +1306 -1306
  34. package/src/polyfills/zlib.ts +881 -881
  35. package/src/script-engine.ts +3722 -3694
  36. package/src/sdk/nodepod-process.ts +94 -86
  37. package/src/sdk/nodepod.ts +52 -6
  38. package/src/sdk/types.ts +82 -77
  39. package/src/threading/process-manager.ts +11 -0
  40. package/src/threading/worker-protocol.ts +358 -358
  41. package/dist/index-B8lyh_ti.js.map +0 -1
  42. package/dist/index-C-TQIrdG.cjs.map +0 -1
@@ -19,6 +19,7 @@ export declare class Nodepod {
19
19
  private _sharedVFS;
20
20
  private _syncChannel;
21
21
  private _unwatchVFS;
22
+ private _handler;
22
23
  private constructor();
23
24
  static boot(opts?: NodepodOptions): Promise<Nodepod>;
24
25
  spawn(cmd: string, args?: string[], opts?: SpawnOptions): Promise<NodepodProcess>;
@@ -27,10 +28,28 @@ export declare class Nodepod {
27
28
  setPreviewScript(script: string): Promise<void>;
28
29
  clearPreviewScript(): Promise<void>;
29
30
  port(num: number): string | null;
30
- private static readonly SHALLOW_EXCLUDE;
31
+ /** Directory names excluded from snapshots at any depth when shallow=true. */
32
+ private static readonly SHALLOW_EXCLUDE_DIRS;
31
33
  snapshot(opts?: SnapshotOptions): Snapshot;
32
34
  restore(snapshot: Snapshot, opts?: SnapshotOptions): Promise<void>;
33
35
  teardown(): void;
36
+ memoryStats(): {
37
+ vfs: {
38
+ fileCount: number;
39
+ totalBytes: number;
40
+ dirCount: number;
41
+ watcherCount: number;
42
+ };
43
+ engine: {
44
+ moduleCacheSize: number;
45
+ transformCacheSize: number;
46
+ };
47
+ heap: {
48
+ usedMB: number;
49
+ totalMB: number;
50
+ limitMB: number;
51
+ } | null;
52
+ };
34
53
  get volume(): MemoryVolume;
35
54
  get engine(): ScriptEngine;
36
55
  get packages(): DependencyInstaller;
@@ -1,4 +1,5 @@
1
1
  import type { VolumeSnapshot } from "../engine-types";
2
+ import type { MemoryHandlerOptions } from "../memory-handler";
2
3
  export interface NodepodOptions {
3
4
  files?: Record<string, string | Uint8Array>;
4
5
  env?: Record<string, string>;
@@ -7,6 +8,10 @@ export interface NodepodOptions {
7
8
  onServerReady?: (port: number, url: string) => void;
8
9
  /** Show a small "nodepod" watermark link in preview iframes. Defaults to true. */
9
10
  watermark?: boolean;
11
+ /** Memory optimization settings. Omit to use defaults. */
12
+ memory?: MemoryHandlerOptions;
13
+ /** Cache installed node_modules in IndexedDB for faster re-boots. Default: true. */
14
+ enableSnapshotCache?: boolean;
10
15
  }
11
16
  export interface TerminalTheme {
12
17
  background?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scelar/nodepod",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Browser-native Node.js runtime environment",
5
5
  "type": "module",
6
6
  "license": "MIT WITH Commons-Clause",
package/src/index.ts CHANGED
@@ -170,6 +170,8 @@ export type {
170
170
  Snapshot,
171
171
  SpawnOptions,
172
172
  } from "./sdk/types";
173
+ export { MemoryHandler, LRUCache } from "./memory-handler";
174
+ export type { MemoryHandlerOptions } from "./memory-handler";
173
175
 
174
176
  /* ---- Threading / Worker Infrastructure ---- */
175
177
 
@@ -0,0 +1,168 @@
1
+ // Centralized memory optimization handler for Nodepod.
2
+ // Provides LRU caches, heap monitoring, and pressure callbacks.
3
+
4
+ import type { FileStat } from './memory-volume';
5
+
6
+ /* ---- LRU Cache ---- */
7
+
8
+ export class LRUCache<K, V> {
9
+ private _map = new Map<K, V>();
10
+ private _capacity: number;
11
+
12
+ constructor(capacity: number) {
13
+ this._capacity = Math.max(1, capacity);
14
+ }
15
+
16
+ get(key: K): V | undefined {
17
+ if (!this._map.has(key)) return undefined;
18
+ const value = this._map.get(key)!;
19
+ // Move to most-recently-used position
20
+ this._map.delete(key);
21
+ this._map.set(key, value);
22
+ return value;
23
+ }
24
+
25
+ set(key: K, value: V): void {
26
+ if (this._map.has(key)) {
27
+ this._map.delete(key);
28
+ } else if (this._map.size >= this._capacity) {
29
+ // Evict least-recently-used (first entry in Map)
30
+ const oldest = this._map.keys().next().value;
31
+ if (oldest !== undefined) this._map.delete(oldest);
32
+ }
33
+ this._map.set(key, value);
34
+ }
35
+
36
+ has(key: K): boolean {
37
+ return this._map.has(key);
38
+ }
39
+
40
+ delete(key: K): boolean {
41
+ return this._map.delete(key);
42
+ }
43
+
44
+ clear(): void {
45
+ this._map.clear();
46
+ }
47
+
48
+ get size(): number {
49
+ return this._map.size;
50
+ }
51
+
52
+ keys(): IterableIterator<K> {
53
+ return this._map.keys();
54
+ }
55
+
56
+ values(): IterableIterator<V> {
57
+ return this._map.values();
58
+ }
59
+ }
60
+
61
+ /* ---- Options ---- */
62
+
63
+ export interface MemoryHandlerOptions {
64
+ /** LRU capacity for path normalization cache. Default: 2048 */
65
+ pathNormCacheSize?: number;
66
+ /** LRU capacity for stat result cache. Default: 512 */
67
+ statCacheSize?: number;
68
+ /** LRU capacity for module resolve cache. Default: 4096 */
69
+ resolveCacheSize?: number;
70
+ /** LRU capacity for package.json manifest cache. Default: 256 */
71
+ manifestCacheSize?: number;
72
+ /** LRU capacity for source transform cache. Default: 512 */
73
+ transformCacheSize?: number;
74
+ /** Max modules before trimming node_modules entries. Default: 512 */
75
+ moduleSoftCacheSize?: number;
76
+ /** Heap usage threshold in MB to trigger pressure callbacks. Default: 350 */
77
+ heapWarnThresholdMB?: number;
78
+ /** Monitoring poll interval in ms. Default: 30000 */
79
+ monitorIntervalMs?: number;
80
+ /** Max process stdout/stderr accumulation in bytes. Default: 4194304 (4MB) */
81
+ maxProcessOutputBytes?: number;
82
+ }
83
+
84
+ const DEFAULTS: Required<MemoryHandlerOptions> = {
85
+ pathNormCacheSize: 2048,
86
+ statCacheSize: 512,
87
+ resolveCacheSize: 4096,
88
+ manifestCacheSize: 256,
89
+ transformCacheSize: 512,
90
+ moduleSoftCacheSize: 512,
91
+ heapWarnThresholdMB: 350,
92
+ monitorIntervalMs: 30_000,
93
+ maxProcessOutputBytes: 4_194_304,
94
+ };
95
+
96
+ /* ---- MemoryHandler ---- */
97
+
98
+ export class MemoryHandler {
99
+ readonly options: Required<MemoryHandlerOptions>;
100
+ readonly pathNormCache: LRUCache<string, string>;
101
+ readonly statCache: LRUCache<string, FileStat>;
102
+ readonly transformCache: LRUCache<string, string>;
103
+
104
+ private _monitorTimer: ReturnType<typeof setInterval> | null = null;
105
+ private _pressureCallbacks: Array<() => void> = [];
106
+ private _destroyed = false;
107
+
108
+ constructor(opts?: MemoryHandlerOptions) {
109
+ this.options = { ...DEFAULTS, ...opts };
110
+ this.pathNormCache = new LRUCache(this.options.pathNormCacheSize);
111
+ this.statCache = new LRUCache(this.options.statCacheSize);
112
+ this.transformCache = new LRUCache(this.options.transformCacheSize);
113
+ }
114
+
115
+ /** Invalidate a cached stat entry (call on file write/delete). */
116
+ invalidateStat(normalizedPath: string): void {
117
+ this.statCache.delete(normalizedPath);
118
+ }
119
+
120
+ /** Register a callback to be invoked when heap pressure is detected. Returns unsubscribe fn. */
121
+ onPressure(cb: () => void): () => void {
122
+ this._pressureCallbacks.push(cb);
123
+ return () => {
124
+ const idx = this._pressureCallbacks.indexOf(cb);
125
+ if (idx >= 0) this._pressureCallbacks.splice(idx, 1);
126
+ };
127
+ }
128
+
129
+ /** Start periodic heap monitoring. */
130
+ startMonitoring(): void {
131
+ if (this._monitorTimer || this._destroyed) return;
132
+ this._monitorTimer = setInterval(() => this._checkHeap(), this.options.monitorIntervalMs);
133
+ }
134
+
135
+ /** Stop monitoring. */
136
+ stopMonitoring(): void {
137
+ if (this._monitorTimer) {
138
+ clearInterval(this._monitorTimer);
139
+ this._monitorTimer = null;
140
+ }
141
+ }
142
+
143
+ /** Clear all owned caches. */
144
+ flush(): void {
145
+ this.pathNormCache.clear();
146
+ this.statCache.clear();
147
+ this.transformCache.clear();
148
+ }
149
+
150
+ /** Full cleanup — stop monitoring, flush caches. */
151
+ destroy(): void {
152
+ this._destroyed = true;
153
+ this.stopMonitoring();
154
+ this.flush();
155
+ this._pressureCallbacks.length = 0;
156
+ }
157
+
158
+ private _checkHeap(): void {
159
+ const perf = typeof performance !== 'undefined' ? (performance as any) : null;
160
+ if (!perf?.memory) return;
161
+ const usedMB = perf.memory.usedJSHeapSize / 1_048_576;
162
+ if (usedMB > this.options.heapWarnThresholdMB) {
163
+ for (const cb of this._pressureCallbacks) {
164
+ try { cb(); } catch { /* ignore */ }
165
+ }
166
+ }
167
+ }
168
+ }
@@ -3,6 +3,7 @@
3
3
  import type { VolumeSnapshot, VolumeEntry } from './engine-types';
4
4
  import { bytesToBase64, base64ToBytes } from './helpers/byte-encoding';
5
5
  import { MOCK_IDS, MOCK_FS } from './constants/config';
6
+ import type { MemoryHandler } from './memory-handler';
6
7
 
7
8
  export interface VolumeNode {
8
9
  kind: 'file' | 'directory' | 'symlink';
@@ -172,8 +173,10 @@ export class MemoryVolume {
172
173
  private textDecoder = new TextDecoder();
173
174
  private activeWatchers = new Map<string, Set<ActiveWatcher>>();
174
175
  private subscribers = new Map<string, Set<VolumeEventHandler>>();
176
+ private _handler: MemoryHandler | null;
175
177
 
176
- constructor() {
178
+ constructor(handler?: MemoryHandler | null) {
179
+ this._handler = handler ?? null;
177
180
  this.tree = {
178
181
  kind: 'directory',
179
182
  children: new Map(),
@@ -216,15 +219,55 @@ export class MemoryVolume {
216
219
  }
217
220
  }
218
221
 
222
+ // ---- Stats ----
223
+
224
+ getStats(): { fileCount: number; totalBytes: number; dirCount: number; watcherCount: number } {
225
+ let fileCount = 0;
226
+ let totalBytes = 0;
227
+ let dirCount = 0;
228
+ const walk = (node: VolumeNode) => {
229
+ if (node.kind === 'file') {
230
+ fileCount++;
231
+ totalBytes += node.content?.byteLength ?? 0;
232
+ } else if (node.kind === 'directory') {
233
+ dirCount++;
234
+ if (node.children) {
235
+ for (const child of node.children.values()) walk(child);
236
+ }
237
+ }
238
+ };
239
+ walk(this.tree);
240
+ let watcherCount = 0;
241
+ for (const set of this.activeWatchers.values()) watcherCount += set.size;
242
+ return { fileCount, totalBytes, dirCount, watcherCount };
243
+ }
244
+
245
+ /** Clean up all watchers, subscribers, and global listeners. */
246
+ dispose(): void {
247
+ this.activeWatchers.clear();
248
+ this.subscribers.clear();
249
+ this.globalChangeListeners.clear();
250
+ if (this._handler) {
251
+ this._handler.statCache.clear();
252
+ this._handler.pathNormCache.clear();
253
+ }
254
+ }
255
+
219
256
  // ---- Snapshot serialization ----
220
257
 
221
- toSnapshot(excludePrefixes?: string[]): VolumeSnapshot {
258
+ toSnapshot(excludePrefixes?: string[], excludeDirNames?: Set<string>): VolumeSnapshot {
222
259
  const entries: VolumeEntry[] = [];
223
- this.collectEntries('/', this.tree, entries, excludePrefixes);
260
+ this.collectEntries('/', this.tree, entries, excludePrefixes, excludeDirNames);
224
261
  return { entries };
225
262
  }
226
263
 
227
- private collectEntries(currentPath: string, node: VolumeNode, result: VolumeEntry[], excludePrefixes?: string[]): void {
264
+ private collectEntries(
265
+ currentPath: string,
266
+ node: VolumeNode,
267
+ result: VolumeEntry[],
268
+ excludePrefixes?: string[],
269
+ excludeDirNames?: Set<string>,
270
+ ): void {
228
271
  if (excludePrefixes) {
229
272
  for (const prefix of excludePrefixes) {
230
273
  if (currentPath === prefix || currentPath.startsWith(prefix + '/')) return;
@@ -243,8 +286,10 @@ export class MemoryVolume {
243
286
  result.push({ path: currentPath, kind: 'directory' });
244
287
  if (node.children) {
245
288
  for (const [name, child] of node.children) {
289
+ // Skip excluded directory names at any depth (e.g. node_modules, .cache)
290
+ if (excludeDirNames && child.kind === 'directory' && excludeDirNames.has(name)) continue;
246
291
  const childPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
247
- this.collectEntries(childPath, child, result, excludePrefixes);
292
+ this.collectEntries(childPath, child, result, excludePrefixes, excludeDirNames);
248
293
  }
249
294
  }
250
295
  }
@@ -313,6 +358,10 @@ export class MemoryVolume {
313
358
  // ---- Path utilities ----
314
359
 
315
360
  private normalize(p: string): string {
361
+ if (this._handler) {
362
+ const cached = this._handler.pathNormCache.get(p);
363
+ if (cached !== undefined) return cached;
364
+ }
316
365
  if (!p.startsWith('/')) p = '/' + p;
317
366
  const parts = p.split('/').filter(Boolean);
318
367
  const resolved: string[] = [];
@@ -320,7 +369,9 @@ export class MemoryVolume {
320
369
  if (part === '..') resolved.pop();
321
370
  else if (part !== '.') resolved.push(part);
322
371
  }
323
- return '/' + resolved.join('/');
372
+ const result = '/' + resolved.join('/');
373
+ if (this._handler) this._handler.pathNormCache.set(p, result);
374
+ return result;
324
375
  }
325
376
 
326
377
  // assumes pre-normalized input (starts with '/', no '..' or double slashes)
@@ -418,6 +469,8 @@ export class MemoryVolume {
418
469
  modified: Date.now(),
419
470
  });
420
471
 
472
+ if (this._handler) this._handler.invalidateStat(norm);
473
+
421
474
  if (notify) {
422
475
  this.triggerWatchers(norm, existed ? 'change' : 'rename');
423
476
  this.broadcast('change', norm, typeof data === 'string' ? data : this.textDecoder.decode(data));
@@ -433,13 +486,19 @@ export class MemoryVolume {
433
486
 
434
487
  statSync(p: string): FileStat {
435
488
  const norm = this.normalize(p);
489
+
490
+ if (this._handler) {
491
+ const cached = this._handler.statCache.get(norm);
492
+ if (cached !== undefined) return cached;
493
+ }
494
+
436
495
  const node = this.locate(norm);
437
496
  if (!node) throw makeSystemError('ENOENT', 'stat', p);
438
497
 
439
498
  const fileSize = node.kind === 'file' ? (node.content?.length || 0) : 0;
440
499
  const ts = node.modified;
441
500
 
442
- return {
501
+ const result: FileStat = {
443
502
  isFile: () => node.kind === 'file',
444
503
  isDirectory: () => node.kind === 'directory',
445
504
  isSymbolicLink: () => false,
@@ -470,6 +529,9 @@ export class MemoryVolume {
470
529
  ctimeNs: BigInt(ts) * 1000000n,
471
530
  birthtimeNs: BigInt(ts) * 1000000n,
472
531
  };
532
+
533
+ if (this._handler) this._handler.statCache.set(norm, result);
534
+ return result;
473
535
  }
474
536
 
475
537
  lstatSync(p: string): FileStat {
@@ -579,6 +641,7 @@ export class MemoryVolume {
579
641
  if (target.kind !== 'file') throw makeSystemError('EISDIR', 'unlink', p);
580
642
 
581
643
  parent.children!.delete(name);
644
+ if (this._handler) this._handler.invalidateStat(norm);
582
645
  this.triggerWatchers(norm, 'rename');
583
646
  this.broadcast('delete', norm);
584
647
  this.notifyGlobalListeners(norm, 'unlink');
@@ -600,6 +663,7 @@ export class MemoryVolume {
600
663
  if (target.children!.size > 0) throw makeSystemError('ENOTEMPTY', 'rmdir', p);
601
664
 
602
665
  parent.children!.delete(name);
666
+ if (this._handler) this._handler.invalidateStat(norm);
603
667
  }
604
668
 
605
669
  renameSync(from: string, to: string): void {
@@ -938,7 +1002,7 @@ export class MemoryVolume {
938
1002
  const self = this;
939
1003
  const pending: Uint8Array[] = [];
940
1004
  const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
941
- const enc = new TextEncoder();
1005
+ const enc = this.textEncoder;
942
1006
 
943
1007
  return {
944
1008
  write(data: string | Uint8Array): boolean {
@@ -13,6 +13,9 @@ import { downloadAndExtract } from "./archive-extractor";
13
13
  import { convertPackage, prepareTransformer } from "../module-transformer";
14
14
  import type { PackageManifest } from "../types/manifest";
15
15
  import * as path from "../polyfills/path";
16
+ import type { IDBSnapshotCache } from "../persistence/idb-cache";
17
+ import { quickDigest } from "../helpers/digest";
18
+ import { base64ToBytes } from "../helpers/byte-encoding";
16
19
 
17
20
  // ---------------------------------------------------------------------------
18
21
  // Public types
@@ -87,11 +90,13 @@ export class DependencyInstaller {
87
90
  private vol: MemoryVolume;
88
91
  private registryClient: RegistryClient;
89
92
  private workingDir: string;
93
+ private _snapshotCache: IDBSnapshotCache | null;
90
94
 
91
- constructor(vol: MemoryVolume, opts: { cwd?: string } & RegistryConfig = {}) {
95
+ constructor(vol: MemoryVolume, opts: { cwd?: string; snapshotCache?: IDBSnapshotCache | null } & RegistryConfig = {}) {
92
96
  this.vol = vol;
93
97
  this.registryClient = new RegistryClient(opts);
94
98
  this.workingDir = opts.cwd || "/";
99
+ this._snapshotCache = opts.snapshotCache ?? null;
95
100
  }
96
101
 
97
102
  // -----------------------------------------------------------------------
@@ -157,6 +162,37 @@ export class DependencyInstaller {
157
162
  const raw = this.vol.readFileSync(jsonPath, "utf8");
158
163
  const manifest: PackageManifest = JSON.parse(raw);
159
164
 
165
+ // Check IDB snapshot cache — skip full install if we have a cached node_modules
166
+ const cacheKey = this._snapshotCache ? quickDigest(raw) : null;
167
+ if (this._snapshotCache && cacheKey) {
168
+ try {
169
+ const cached = await this._snapshotCache.get(cacheKey);
170
+ if (cached) {
171
+ onProgress?.("Restoring cached node_modules...");
172
+ const { entries } = cached;
173
+ // Restore only node_modules entries from the snapshot
174
+ for (const entry of entries) {
175
+ if (!entry.path.includes('/node_modules/')) continue;
176
+ if (entry.kind === 'directory') {
177
+ if (!this.vol.existsSync(entry.path)) {
178
+ this.vol.mkdirSync(entry.path, { recursive: true });
179
+ }
180
+ } else if (entry.kind === 'file' && entry.data) {
181
+ const parentDir = entry.path.substring(0, entry.path.lastIndexOf('/')) || '/';
182
+ if (parentDir !== '/' && !this.vol.existsSync(parentDir)) {
183
+ this.vol.mkdirSync(parentDir, { recursive: true });
184
+ }
185
+ this.vol.writeFileSync(entry.path, base64ToBytes(entry.data));
186
+ }
187
+ }
188
+ onProgress?.(`Restored ${entries.length} cached entries`);
189
+ return { resolved: new Map(), newPackages: [] };
190
+ }
191
+ } catch {
192
+ // Cache miss or error — proceed with normal install
193
+ }
194
+ }
195
+
160
196
  onProgress?.("Resolving dependency tree...");
161
197
 
162
198
  const resolutionOpts: ResolutionConfig = {
@@ -170,6 +206,18 @@ export class DependencyInstaller {
170
206
 
171
207
  const newPkgs = await this.materializePackages(tree, flags);
172
208
 
209
+ // Cache the installed node_modules snapshot for future reuse
210
+ if (this._snapshotCache && cacheKey && newPkgs.length > 0) {
211
+ try {
212
+ const snapshot = this.vol.toSnapshot();
213
+ // Filter to only node_modules entries to keep cache lean
214
+ const nmSnapshot = {
215
+ entries: snapshot.entries.filter(e => e.path.includes('/node_modules/')),
216
+ };
217
+ await this._snapshotCache.set(cacheKey, nmSnapshot);
218
+ } catch { /* cache write failure is non-fatal */ }
219
+ }
220
+
173
221
  onProgress?.(`Installed ${tree.size} package(s)`);
174
222
 
175
223
  return { resolved: tree, newPackages: newPkgs };