@pnpm/worker 1100.0.1 → 1100.1.0

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.
package/lib/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { PnpmError } from '@pnpm/error';
2
2
  import type { FilesMap, PackageFilesResponse } from '@pnpm/store.cafs-types';
3
3
  import type { StoreIndex } from '@pnpm/store.index';
4
4
  import type { BundledManifest } from '@pnpm/types';
5
- import type { AddDirToStoreMessage, LinkPkgMessage, SymlinkAllModulesMessage, TarballExtractMessage } from './types.js';
5
+ import type { AddDirToStoreMessage, FetchAndWriteCafsMessage, LinkPkgMessage, SymlinkAllModulesMessage, TarballExtractMessage } from './types.js';
6
6
  export declare function restartWorkerPool(): Promise<void>;
7
7
  export declare function finishWorkers(): Promise<void>;
8
8
  export declare function calcMaxWorkers(): number;
@@ -31,11 +31,16 @@ export declare class TarballIntegrityError extends PnpmError {
31
31
  url: string;
32
32
  });
33
33
  }
34
- type AddFilesFromTarballOptions = Pick<TarballExtractMessage, 'buffer' | 'storeDir' | 'filesIndexFile' | 'integrity' | 'readManifest' | 'pkg' | 'appendManifest'> & {
34
+ type AddFilesFromTarballOptions = Pick<TarballExtractMessage, 'buffer' | 'storeDir' | 'filesIndexFile' | 'integrity' | 'readManifest' | 'pkg' | 'appendManifest' | 'ignoreFilePattern'> & {
35
35
  storeIndex: StoreIndex;
36
36
  url: string;
37
37
  };
38
38
  export declare function addFilesFromTarball(opts: AddFilesFromTarballOptions): Promise<AddFilesResult>;
39
+ export declare function fetchAndWriteCafsFiles(opts: {
40
+ registryUrl: string;
41
+ storeDir: string;
42
+ digests: FetchAndWriteCafsMessage['digests'];
43
+ }): Promise<number>;
39
44
  export interface ReadPkgFromCafsContext {
40
45
  storeDir: string;
41
46
  verifyStoreIntegrity: boolean;
@@ -54,6 +59,19 @@ export interface ReadPkgFromCafsResult {
54
59
  bundledManifest?: BundledManifest;
55
60
  }
56
61
  export declare function readPkgFromCafs(ctx: ReadPkgFromCafsContext, filesIndexFile: string, opts?: ReadPkgFromCafsOptions): Promise<ReadPkgFromCafsResult>;
62
+ /**
63
+ * Temporarily change import concurrency. Called by the pnpm agent code path
64
+ * where there's no concurrent fetching competing for workers. Returns a
65
+ * disposer that restores the previous limiter — callers must invoke it (in a
66
+ * finally block) to avoid leaking the mutation to other installs in the same
67
+ * process (e.g. test suites).
68
+ *
69
+ * If two installs overlap, the disposer for the outer install would otherwise
70
+ * clobber the inner one's still-active limiter. Each disposer captures the
71
+ * limiter it installed and only restores when it's still the active one,
72
+ * leaving any newer override in place.
73
+ */
74
+ export declare function setImportConcurrency(concurrency: number): () => void;
57
75
  export declare function importPackage(opts: Omit<LinkPkgMessage, 'type'>): Promise<{
58
76
  isBuilt: boolean;
59
77
  importMethod: string | undefined;
package/lib/index.js CHANGED
@@ -144,6 +144,29 @@ export async function addFilesFromTarball(opts) {
144
144
  readManifest: opts.readManifest,
145
145
  pkg: opts.pkg,
146
146
  appendManifest: opts.appendManifest,
147
+ ignoreFilePattern: opts.ignoreFilePattern,
148
+ });
149
+ });
150
+ }
151
+ export async function fetchAndWriteCafsFiles(opts) {
152
+ if (!workerPool) {
153
+ workerPool = createTarballWorkerPool();
154
+ }
155
+ const localWorker = await workerPool.checkoutWorkerAsync(true);
156
+ return new Promise((resolve, reject) => {
157
+ localWorker.once('message', ({ status, error, filesWritten }) => {
158
+ workerPool.checkinWorker(localWorker);
159
+ if (status === 'error') {
160
+ reject(new PnpmError('CAFS_FETCH_WRITE', error.message));
161
+ return;
162
+ }
163
+ resolve(filesWritten);
164
+ });
165
+ localWorker.postMessage({
166
+ type: 'fetch-and-write-cafs',
167
+ registryUrl: opts.registryUrl,
168
+ storeDir: opts.storeDir,
169
+ digests: opts.digests,
147
170
  });
148
171
  });
149
172
  }
@@ -178,7 +201,32 @@ export async function readPkgFromCafs(ctx, filesIndexFile, opts) {
178
201
  // so, running them in parallel helps only to a point.
179
202
  // With local experimenting it was discovered that running 4 workers gives the best results.
180
203
  // Adding more workers actually makes installation slower.
181
- const limitImportingPackage = pLimit(4);
204
+ let limitImportingPackage = pLimit(4);
205
+ /**
206
+ * Temporarily change import concurrency. Called by the pnpm agent code path
207
+ * where there's no concurrent fetching competing for workers. Returns a
208
+ * disposer that restores the previous limiter — callers must invoke it (in a
209
+ * finally block) to avoid leaking the mutation to other installs in the same
210
+ * process (e.g. test suites).
211
+ *
212
+ * If two installs overlap, the disposer for the outer install would otherwise
213
+ * clobber the inner one's still-active limiter. Each disposer captures the
214
+ * limiter it installed and only restores when it's still the active one,
215
+ * leaving any newer override in place.
216
+ */
217
+ export function setImportConcurrency(concurrency) {
218
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
219
+ throw new Error(`setImportConcurrency: expected a positive integer, got ${concurrency}`);
220
+ }
221
+ const previous = limitImportingPackage;
222
+ const installed = pLimit(concurrency);
223
+ limitImportingPackage = installed;
224
+ return () => {
225
+ if (limitImportingPackage === installed) {
226
+ limitImportingPackage = previous;
227
+ }
228
+ };
229
+ }
182
230
  export async function importPackage(opts) {
183
231
  return limitImportingPackage(async () => {
184
232
  if (!workerPool) {
package/lib/start.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import util from 'node:util';
4
5
  import { parentPort } from 'node:worker_threads';
5
6
  import { pkgRequiresBuild } from '@pnpm/building.pkg-requires-build';
6
7
  import { formatIntegrity, parseIntegrity } from '@pnpm/crypto.integrity';
@@ -124,6 +125,10 @@ async function handleMessage(message) {
124
125
  parentPort.postMessage({ status: 'success' });
125
126
  break;
126
127
  }
128
+ case 'fetch-and-write-cafs': {
129
+ parentPort.postMessage(await fetchAndWriteCafs(message));
130
+ break;
131
+ }
127
132
  }
128
133
  }
129
134
  catch (e) { // eslint-disable-line
@@ -137,7 +142,7 @@ async function handleMessage(message) {
137
142
  });
138
143
  }
139
144
  }
140
- function addTarballToStore({ buffer, storeDir, integrity, filesIndexFile, appendManifest }) {
145
+ function addTarballToStore({ buffer, storeDir, integrity, filesIndexFile, appendManifest, ignoreFilePattern }) {
141
146
  if (integrity) {
142
147
  const { algorithm, hexDigest } = parseIntegrity(integrity);
143
148
  const calculatedHash = crypto.hash(algorithm, buffer, 'hex');
@@ -157,7 +162,8 @@ function addTarballToStore({ buffer, storeDir, integrity, filesIndexFile, append
157
162
  cafsCache.set(storeDir, createCafs(storeDir));
158
163
  }
159
164
  const cafs = cafsCache.get(storeDir);
160
- let { filesIndex, manifest } = cafs.addFilesFromTarball(buffer, true);
165
+ const ignore = ignoreFilePattern ? makeIgnoreFromPattern(ignoreFilePattern) : undefined;
166
+ let { filesIndex, manifest } = cafs.addFilesFromTarball(buffer, true, ignore);
161
167
  if (appendManifest && manifest == null) {
162
168
  manifest = appendManifest;
163
169
  addManifestToCafs(cafs, filesIndex, appendManifest);
@@ -189,6 +195,21 @@ function calcIntegrity(buffer) {
189
195
  const calculatedHash = crypto.hash('sha512', buffer, 'hex');
190
196
  return formatIntegrity('sha512', calculatedHash);
191
197
  }
198
+ function makeIgnoreFromPattern(pattern) {
199
+ // `ignoreFilePattern` is a public field on FetchOptions, so callers that don't go
200
+ // through the binary-fetcher's validated `archiveFilters` path could still supply a
201
+ // bad regex. Convert the SyntaxError into a PnpmError with a stable code so it's
202
+ // actionable for users.
203
+ let regex;
204
+ try {
205
+ regex = new RegExp(pattern);
206
+ }
207
+ catch (err) {
208
+ const detail = util.types.isNativeError(err) ? `: ${err.message}` : '';
209
+ throw new PnpmError('INVALID_IGNORE_FILE_PATTERN', `Invalid ignoreFilePattern regex${detail}: ${pattern}`);
210
+ }
211
+ return (filename) => regex.test(filename);
212
+ }
192
213
  function packToShared(data) {
193
214
  const packed = packForStorage(data);
194
215
  const shared = new SharedArrayBuffer(packed.byteLength);
@@ -374,4 +395,182 @@ function symlinkAllModules(opts) {
374
395
  }
375
396
  return { status: 'success' };
376
397
  }
398
+ async function fetchAndWriteCafs(message) {
399
+ const http = await import('node:http');
400
+ const https = await import('node:https');
401
+ const { URL } = await import('node:url');
402
+ const { createGunzip } = await import('node:zlib');
403
+ const { contentPathFromHex } = await import('@pnpm/store.cafs');
404
+ // Preserve any path prefix on the agent URL (e.g. https://host/pnpm-agent/)
405
+ // by normalizing the base and using a relative URL.
406
+ const base = message.registryUrl.endsWith('/') ? message.registryUrl : `${message.registryUrl}/`;
407
+ const url = new URL('v1/files', base);
408
+ const requestFn = url.protocol === 'https:' ? https.request : http.request;
409
+ const body = JSON.stringify({ digests: message.digests });
410
+ const createdDirs = new Set();
411
+ // Build a set of digests we actually requested, so we can reject a
412
+ // misbehaving agent that streams unrelated entries and tries to write
413
+ // unbounded files into our CAFS. The set is keyed by `${digest}:${exec}`
414
+ // because the same digest may appear with different modes.
415
+ const requestedDigests = new Set();
416
+ for (const d of message.digests) {
417
+ requestedDigests.add(`${d.digest}:${d.executable ? 'x' : ''}`);
418
+ }
419
+ // Stream: HTTP response → gunzip → parse entries → write to CAFS.
420
+ // No buffering — files are written as data arrives.
421
+ return new Promise((resolve, reject) => {
422
+ let filesWritten = 0;
423
+ let buf = Buffer.alloc(0);
424
+ let headerSkipped = false;
425
+ let endMarkerSeen = false;
426
+ const END_MARKER = Buffer.alloc(64, 0);
427
+ const processBuffer = () => {
428
+ // Skip JSON header on first chunk
429
+ if (!headerSkipped && buf.length >= 4) {
430
+ const jsonLen = buf.readUInt32BE(0);
431
+ if (buf.length >= 4 + jsonLen) {
432
+ buf = buf.subarray(4 + jsonLen);
433
+ headerSkipped = true;
434
+ }
435
+ else {
436
+ return;
437
+ }
438
+ }
439
+ // Parse complete file entries from the buffer
440
+ while (headerSkipped && !endMarkerSeen) {
441
+ if (buf.length < 64)
442
+ break;
443
+ if (buf.subarray(0, 64).equals(END_MARKER)) {
444
+ buf = buf.subarray(64);
445
+ endMarkerSeen = true;
446
+ break;
447
+ }
448
+ if (buf.length < 69)
449
+ break; // 64 digest + 4 size + 1 mode
450
+ const size = buf.readUInt32BE(64);
451
+ const entryLen = 69 + size;
452
+ if (buf.length < entryLen)
453
+ break; // incomplete entry
454
+ const digest = buf.subarray(0, 64).toString('hex');
455
+ const executable = (buf[68] & 0x01) !== 0;
456
+ const requestKey = `${digest}:${executable ? 'x' : ''}`;
457
+ if (!requestedDigests.has(requestKey)) {
458
+ throw new Error(`pnpm agent /v1/files returned an entry that was not requested: digest=${digest} executable=${String(executable)}`);
459
+ }
460
+ // Consume the request so duplicates past the requested count also fail.
461
+ requestedDigests.delete(requestKey);
462
+ const content = buf.subarray(69, entryLen);
463
+ const relPath = contentPathFromHex(executable ? 'exec' : 'nonexec', digest);
464
+ const fullPath = path.join(message.storeDir, relPath);
465
+ const dir = path.dirname(fullPath);
466
+ if (!createdDirs.has(dir)) {
467
+ fs.mkdirSync(dir, { recursive: true });
468
+ createdDirs.add(dir);
469
+ }
470
+ try {
471
+ fs.writeFileSync(fullPath, content, { flag: 'wx', mode: executable ? 0o755 : 0o644 });
472
+ }
473
+ catch (err) {
474
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) {
475
+ throw err;
476
+ }
477
+ // EEXIST means the same digest is already at this CAFS path. CAFS
478
+ // is content-addressed, so a complete file is by definition correct.
479
+ // But a previous process could have crashed mid-write and left a
480
+ // truncated file — the agent path skips integrity verification, so
481
+ // we'd silently install garbage. Detect truncation by size and
482
+ // overwrite atomically if the on-disk file is the wrong length.
483
+ const onDiskSize = fs.statSync(fullPath).size;
484
+ if (onDiskSize !== content.length) {
485
+ const tmpPath = `${fullPath}.tmp-${process.pid}-${Date.now()}`;
486
+ fs.writeFileSync(tmpPath, content, { mode: executable ? 0o755 : 0o644 });
487
+ fs.renameSync(tmpPath, fullPath);
488
+ }
489
+ }
490
+ filesWritten++;
491
+ buf = buf.subarray(entryLen);
492
+ }
493
+ };
494
+ // processBuffer is called from stream event handlers where a thrown
495
+ // exception would become `uncaughtException` and crash the worker.
496
+ // Surface errors via the Promise rejection instead.
497
+ const safeProcessBuffer = () => {
498
+ try {
499
+ processBuffer();
500
+ return true;
501
+ }
502
+ catch (err) {
503
+ reject(err);
504
+ req.destroy();
505
+ return false;
506
+ }
507
+ };
508
+ // Match the NDJSON client timeout — a stalled connection would otherwise
509
+ // hang the install indefinitely waiting on `fileDownloads`.
510
+ const FILES_REQUEST_TIMEOUT_MS = 600_000;
511
+ const req = requestFn(url, {
512
+ method: 'POST',
513
+ timeout: FILES_REQUEST_TIMEOUT_MS,
514
+ headers: {
515
+ 'Content-Type': 'application/json',
516
+ 'Content-Length': Buffer.byteLength(body),
517
+ 'Accept-Encoding': 'gzip',
518
+ },
519
+ }, (res) => {
520
+ // Non-2xx responses are JSON error bodies from the agent server; read
521
+ // and reject so we never try to gunzip an error payload as a file stream.
522
+ if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
523
+ const chunks = [];
524
+ res.on('data', (chunk) => chunks.push(chunk));
525
+ res.on('end', () => {
526
+ reject(new Error(`pnpm agent /v1/files responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`));
527
+ });
528
+ res.on('error', reject);
529
+ return;
530
+ }
531
+ let stream = res;
532
+ if (res.headers['content-encoding'] === 'gzip') {
533
+ const gunzip = createGunzip();
534
+ res.pipe(gunzip);
535
+ stream = gunzip;
536
+ }
537
+ stream.on('data', (chunk) => {
538
+ buf = Buffer.concat([buf, chunk]);
539
+ safeProcessBuffer();
540
+ });
541
+ stream.on('end', () => {
542
+ if (!safeProcessBuffer())
543
+ return;
544
+ // Guard against a truncated response: the server must terminate the
545
+ // stream with the 64-byte end marker. If it didn't, or if there's a
546
+ // partial entry still in `buf`, fail — otherwise we'd silently leave
547
+ // the CAFS missing files.
548
+ if (!endMarkerSeen) {
549
+ reject(new Error('pnpm agent /v1/files stream ended without the end marker'));
550
+ return;
551
+ }
552
+ if (buf.length > 0) {
553
+ reject(new Error(`pnpm agent /v1/files stream left ${buf.length} unparsed bytes after end marker`));
554
+ return;
555
+ }
556
+ // Every received entry was drained from `requestedDigests` as it was
557
+ // parsed; anything still in the set means the server ended cleanly
558
+ // but omitted files, which would silently leave the CAFS incomplete.
559
+ if (requestedDigests.size > 0) {
560
+ const sample = [...requestedDigests].slice(0, 3).join(', ');
561
+ reject(new Error(`pnpm agent /v1/files omitted ${requestedDigests.size} requested entries (e.g. ${sample})`));
562
+ return;
563
+ }
564
+ resolve({ status: 'success', filesWritten });
565
+ });
566
+ stream.on('error', reject);
567
+ });
568
+ req.on('timeout', () => {
569
+ req.destroy(new Error(`pnpm agent /v1/files request timed out after ${FILES_REQUEST_TIMEOUT_MS / 1000}s`));
570
+ });
571
+ req.on('error', reject);
572
+ req.write(body);
573
+ req.end();
574
+ });
575
+ }
377
576
  //# sourceMappingURL=start.js.map
package/lib/types.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface TarballExtractMessage {
17
17
  readManifest?: boolean;
18
18
  pkg?: PkgNameVersion;
19
19
  appendManifest?: DependencyManifest;
20
+ /**
21
+ * Regex source matching the normalized relative path of files inside the tarball that
22
+ * should be skipped. Matching happens after the tar parser strips the top-level directory
23
+ * segment — i.e. against the same path form that is written to `filesIndex`.
24
+ */
25
+ ignoreFilePattern?: string;
20
26
  }
21
27
  export interface LinkPkgMessage {
22
28
  type: 'link';
@@ -65,3 +71,13 @@ export interface HardLinkDirMessage {
65
71
  src: string;
66
72
  destDirs: string[];
67
73
  }
74
+ export interface FetchAndWriteCafsMessage {
75
+ type: 'fetch-and-write-cafs';
76
+ registryUrl: string;
77
+ storeDir: string;
78
+ digests: Array<{
79
+ digest: string;
80
+ size: number;
81
+ executable: boolean;
82
+ }>;
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/worker",
3
- "version": "1100.0.1",
3
+ "version": "1100.1.0",
4
4
  "description": "A worker for extracting package taralls to the store",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -31,15 +31,15 @@
31
31
  "p-limit": "^7.1.0",
32
32
  "semver": "^7.7.2",
33
33
  "@pnpm/building.pkg-requires-build": "1100.0.1",
34
- "@pnpm/crypto.integrity": "1100.0.0",
35
34
  "@pnpm/error": "1100.0.0",
36
- "@pnpm/fs.hard-link-dir": "1100.0.0",
35
+ "@pnpm/crypto.integrity": "1100.0.0",
37
36
  "@pnpm/fs.graceful-fs": "1100.0.0",
37
+ "@pnpm/fs.hard-link-dir": "1100.0.0",
38
+ "@pnpm/store.cafs": "1100.1.0",
38
39
  "@pnpm/fs.symlink-dependency": "1100.0.1",
39
- "@pnpm/store.cafs": "1100.0.1",
40
40
  "@pnpm/store.cafs-types": "1100.0.0",
41
- "@pnpm/store.index": "1100.0.0",
42
- "@pnpm/store.create-cafs-store": "1100.0.1"
41
+ "@pnpm/store.create-cafs-store": "1100.0.3",
42
+ "@pnpm/store.index": "1100.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@pnpm/logger": ">=1001.0.0 <1002.0.0"
@@ -47,9 +47,9 @@
47
47
  "devDependencies": {
48
48
  "@types/is-windows": "^1.0.2",
49
49
  "@types/semver": "7.7.1",
50
- "@pnpm/types": "1101.0.0",
51
50
  "@pnpm/logger": "1100.0.0",
52
- "@pnpm/worker": "1100.0.1"
51
+ "@pnpm/types": "1101.0.0",
52
+ "@pnpm/worker": "1100.1.0"
53
53
  },
54
54
  "engines": {
55
55
  "node": ">=22.13"