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