@pnpm/pnpr.client 1.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
4
+ Copyright (c) 2016-2026 Zoltan Kochan and other contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @pnpm/pnpr.client
2
+
3
+ Client library for the pnpr server. Reads the local store state, sends it to the server, and writes the received files into the content-addressable store.
4
+
5
+ ## How it works
6
+
7
+ 1. Reads integrity hashes from the local store index (`index.db`).
8
+ 2. Sends `POST /v1/install` to the pnpr server with the project's dependencies and the store integrities.
9
+ 3. Parses the NDJSON streaming response — `D`-lines (missing file digests) are dispatched to worker downloads against `/v1/files`, `I`-lines are buffered as raw store-index entries, and the final `L`-line yields the resolved lockfile and stats.
10
+ 4. File download workers write each received file directly to the local CAFS (`files/{hash[:2]}/{hash[2:]}`).
11
+ 5. Writes store index entries for all new packages in a single SQLite transaction.
12
+ 6. Returns the resolved lockfile for use with pnpm's headless install (linking phase).
13
+
14
+ ## Usage
15
+
16
+ This package is used internally by pnpm when the `pnprServer` config option is set. It is not intended to be called directly, but can be used programmatically:
17
+
18
+ ```typescript
19
+ import { fetchFromPnpmRegistry } from '@pnpm/pnpr.client'
20
+ import { StoreIndex } from '@pnpm/store.index'
21
+
22
+ const storeIndex = new StoreIndex('/path/to/store')
23
+
24
+ const { lockfile, stats } = await fetchFromPnpmRegistry({
25
+ registryUrl: 'http://localhost:4000',
26
+ storeDir: '/path/to/store',
27
+ storeIndex,
28
+ dependencies: { react: '^19.0.0' },
29
+ devDependencies: { typescript: '^5.0.0' },
30
+ })
31
+
32
+ console.log(`Resolved ${stats.totalPackages} packages`)
33
+ console.log(`${stats.alreadyInStore} cached, ${stats.filesToDownload} files downloaded`)
34
+ // lockfile is ready for headless install
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ Add to `pnpm-workspace.yaml` to enable automatically during `pnpm install`:
40
+
41
+ ```yaml
42
+ pnprServer: http://localhost:4000
43
+ ```
@@ -0,0 +1,89 @@
1
+ import type { LockfileFile, LockfileObject } from '@pnpm/lockfile.types';
2
+ import { StoreIndex } from '@pnpm/store.index';
3
+ import type { ResponseMetadata } from './protocol.js';
4
+ export interface PnprProject {
5
+ /** Relative dir within the workspace (e.g. "." or "packages/foo") */
6
+ dir: string;
7
+ dependencies?: Record<string, string>;
8
+ devDependencies?: Record<string, string>;
9
+ optionalDependencies?: Record<string, string>;
10
+ }
11
+ export interface FetchFromPnpmRegistryOptions {
12
+ /** URL of the pnpr server */
13
+ registryUrl: string;
14
+ /** Client's store directory */
15
+ storeDir: string;
16
+ /** Client's store index */
17
+ storeIndex: StoreIndex;
18
+ /** Dependencies to resolve (single project) */
19
+ dependencies?: Record<string, string>;
20
+ /** Dev dependencies to resolve (single project) */
21
+ devDependencies?: Record<string, string>;
22
+ /** Optional dependencies to resolve (single project) */
23
+ optionalDependencies?: Record<string, string>;
24
+ /** Multiple projects in a workspace */
25
+ projects?: PnprProject[];
26
+ /**
27
+ * The client's default registry. The server resolves against this
28
+ * (and `namedRegistries`) rather than its own configuration.
29
+ */
30
+ registry?: string;
31
+ /** The client's named-registry aliases (`namedRegistries`). */
32
+ namedRegistries?: Record<string, string>;
33
+ /**
34
+ * The caller's forwarded upstream credentials, keyed by nerf-darted
35
+ * registry URI, so the server resolves/fetches private content as the
36
+ * caller. Distinct from `authorization` (pnpr identity).
37
+ */
38
+ authHeaders?: Record<string, string>;
39
+ /**
40
+ * `Authorization` for the pnpr server's own URL (`undefined` if none):
41
+ * identifies the caller to pnpr's gate and keys the grant table.
42
+ */
43
+ authorization?: string;
44
+ /** Overrides */
45
+ overrides?: Record<string, string>;
46
+ /** Node.js version for resolution */
47
+ nodeVersion?: string;
48
+ /** Minimum release age in minutes */
49
+ minimumReleaseAge?: number;
50
+ /**
51
+ * Existing lockfile for incremental resolution, in the on-disk format
52
+ * the wire protocol carries. The caller reads it with
53
+ * `readWantedLockfileFile` so no in-memory→on-disk round-trip is needed.
54
+ */
55
+ lockfile?: LockfileFile;
56
+ /**
57
+ * `--lockfile-only`: resolve and return only the lockfile — fetch no
58
+ * files into the local store. Forwarded to the server (which skips the
59
+ * file diff); the client ignores the (empty) file payload so the store
60
+ * stays untouched. Mirrors pnpm's resolve + write, fetch nothing, link
61
+ * nothing. See https://github.com/pnpm/pnpm/issues/12146.
62
+ */
63
+ lockfileOnly?: boolean;
64
+ }
65
+ export interface FetchFromPnpmRegistryResult {
66
+ lockfile: LockfileObject;
67
+ stats: ResponseMetadata['stats'];
68
+ /** Promise that resolves when all file downloads are written to CAFS */
69
+ fileDownloads: Promise<void>;
70
+ /** Pre-packed store index entries to write to SQLite */
71
+ indexEntries: Array<{
72
+ key: string;
73
+ buffer: Uint8Array;
74
+ }>;
75
+ }
76
+ /**
77
+ * Fetch resolved dependencies from a pnpr server in a single round trip.
78
+ *
79
+ * `POST /v1/install` (with `inlineFiles`) answers with one gzipped binary
80
+ * body: a length-prefixed JSON header (lockfile, stats, store-index
81
+ * entries, or verification violations) followed by the missing files'
82
+ * contents as binary frames. We parse the header here and hand the file
83
+ * frames to a worker that writes them straight into the CAFS.
84
+ */
85
+ export declare function fetchFromPnpmRegistry(opts: FetchFromPnpmRegistryOptions): Promise<FetchFromPnpmRegistryResult>;
86
+ export declare function writeRawIndexEntries(indexEntries: Array<{
87
+ key: string;
88
+ buffer: Uint8Array;
89
+ }>, storeIndex: StoreIndex): void;
@@ -0,0 +1,179 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { URL } from 'node:url';
4
+ import { gunzip } from 'node:zlib';
5
+ import { convertToLockfileObject } from '@pnpm/lockfile.fs';
6
+ import { StoreIndex } from '@pnpm/store.index';
7
+ import { writeCafsFiles } from '@pnpm/worker';
8
+ /**
9
+ * Fetch resolved dependencies from a pnpr server in a single round trip.
10
+ *
11
+ * `POST /v1/install` (with `inlineFiles`) answers with one gzipped binary
12
+ * body: a length-prefixed JSON header (lockfile, stats, store-index
13
+ * entries, or verification violations) followed by the missing files'
14
+ * contents as binary frames. We parse the header here and hand the file
15
+ * frames to a worker that writes them straight into the CAFS.
16
+ */
17
+ export async function fetchFromPnpmRegistry(opts) {
18
+ const storeIntegrities = readStoreIntegrities(opts.storeIndex);
19
+ const projects = opts.projects ?? [{
20
+ dir: '.',
21
+ dependencies: opts.dependencies,
22
+ devDependencies: opts.devDependencies,
23
+ optionalDependencies: opts.optionalDependencies,
24
+ }];
25
+ const requestBody = JSON.stringify({
26
+ projects,
27
+ registry: opts.registry,
28
+ namedRegistries: opts.namedRegistries,
29
+ authHeaders: opts.authHeaders,
30
+ overrides: opts.overrides,
31
+ nodeVersion: opts.nodeVersion ?? process.version.slice(1),
32
+ os: process.platform,
33
+ arch: process.arch,
34
+ minimumReleaseAge: opts.minimumReleaseAge,
35
+ // Sent as-is: `opts.lockfile` is already the on-disk format the wire
36
+ // protocol carries (split `packages`/`snapshots`, `{ specifier, version }`
37
+ // importer deps).
38
+ lockfile: opts.lockfile,
39
+ lockfileOnly: opts.lockfileOnly,
40
+ storeIntegrities,
41
+ inlineFiles: true,
42
+ });
43
+ const body = await postInstall(opts.registryUrl, requestBody, opts.authorization);
44
+ // The combined response is `[u32 header length][header JSON][file frames]`.
45
+ if (body.length < 4) {
46
+ throw new Error('pnpr server returned a truncated /v1/install response');
47
+ }
48
+ const headerLength = body.readUInt32BE(0);
49
+ const header = JSON.parse(body.subarray(4, 4 + headerLength).toString('utf-8'));
50
+ if (header.violations != null && header.violations.length > 0) {
51
+ const rendered = header.violations
52
+ .map((violation) => ` ${violation.name}@${violation.version}: ${violation.reason}`)
53
+ .join('\n');
54
+ throw new Error(`pnpr server rejected the lockfile under the verification policy:\n${rendered}`);
55
+ }
56
+ const indexEntries = (header.indexEntries ?? []).map(({ key, b64 }) => ({
57
+ key,
58
+ buffer: new Uint8Array(Buffer.from(b64, 'base64')),
59
+ }));
60
+ // `--lockfile-only` fetches nothing: there are no file frames to write
61
+ // (the server sends only the end-of-stream marker), so leave the store
62
+ // untouched.
63
+ const fileDownloads = opts.lockfileOnly
64
+ ? Promise.resolve()
65
+ : writeCafsFiles({
66
+ storeDir: opts.storeDir,
67
+ payload: body.subarray(4 + headerLength),
68
+ }).then(() => { });
69
+ return {
70
+ // The server speaks the on-disk lockfile format; convert it to the
71
+ // in-memory `LockfileObject` the rest of pnpm consumes.
72
+ lockfile: convertToLockfileObject(header.lockfile),
73
+ stats: header.stats,
74
+ fileDownloads,
75
+ indexEntries,
76
+ };
77
+ }
78
+ function readStoreIntegrities(storeIndex) {
79
+ const seen = new Set();
80
+ for (const key of storeIndex.keys()) {
81
+ const tabIdx = key.indexOf('\t');
82
+ if (tabIdx === -1)
83
+ continue;
84
+ const integrity = key.slice(0, tabIdx);
85
+ // StoreIndex also stores non-integrity keys (e.g. git-hosted entries
86
+ // keyed by URL). Filter to actual SRI hashes — sending those over to
87
+ // the pnpr server would just bloat the request without ever matching.
88
+ if (!isIntegrityLike(integrity))
89
+ continue;
90
+ seen.add(integrity);
91
+ }
92
+ return [...seen];
93
+ }
94
+ function isIntegrityLike(value) {
95
+ return value.startsWith('sha512-') ||
96
+ value.startsWith('sha256-') ||
97
+ value.startsWith('sha1-');
98
+ }
99
+ const REQUEST_TIMEOUT = 600_000; // 10 minutes — server-side resolution can be slow on first run
100
+ /**
101
+ * `POST /v1/install` and return the full response body, decompressed.
102
+ *
103
+ * `urlPath` resolution normalizes the base to end with "/" so a path
104
+ * prefix configured on the pnpr server URL (e.g. https://host/pnpr/) is
105
+ * preserved.
106
+ */
107
+ async function postInstall(registryUrl, body, authorization) {
108
+ const base = registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`;
109
+ const url = new URL('v1/install', base);
110
+ const requestFn = url.protocol === 'https:' ? https.request : http.request;
111
+ const headers = {
112
+ 'Content-Type': 'application/json',
113
+ 'Content-Length': Buffer.byteLength(body),
114
+ 'Accept-Encoding': 'gzip',
115
+ };
116
+ // Identify the caller to the pnpr server's access gate so protected
117
+ // packages resolve and the per-user grant table keys on the right user.
118
+ if (authorization != null) {
119
+ headers.Authorization = authorization;
120
+ }
121
+ return new Promise((resolve, reject) => {
122
+ const req = requestFn(url, {
123
+ method: 'POST',
124
+ timeout: REQUEST_TIMEOUT,
125
+ headers,
126
+ }, (res) => {
127
+ const chunks = [];
128
+ res.on('data', (chunk) => chunks.push(chunk));
129
+ res.on('end', () => {
130
+ const raw = Buffer.concat(chunks);
131
+ // The server gzips both the install body and its JSON error bodies
132
+ // (e.g. a 401/403 access denial), so decompress *before* branching
133
+ // on the status code — otherwise an error surfaces as binary
134
+ // garbage instead of the server's message. Skip it only when the
135
+ // HTTP stack already decompressed (no gzip magic bytes).
136
+ const finish = (body) => {
137
+ if (res.statusCode !== 200) {
138
+ reject(new Error(`pnpr server responded with ${res.statusCode}: ${body.toString('utf-8')}`));
139
+ }
140
+ else {
141
+ resolve(body);
142
+ }
143
+ };
144
+ if (res.headers['content-encoding'] === 'gzip' || (raw[0] === 0x1f && raw[1] === 0x8b)) {
145
+ gunzip(raw, (err, decompressed) => {
146
+ if (err)
147
+ reject(err);
148
+ else
149
+ finish(decompressed);
150
+ });
151
+ }
152
+ else {
153
+ finish(raw);
154
+ }
155
+ });
156
+ res.on('error', reject);
157
+ });
158
+ req.on('timeout', () => {
159
+ req.destroy(new Error(`pnpr server request timed out after ${REQUEST_TIMEOUT / 1000}s (${registryUrl})`));
160
+ });
161
+ req.on('error', (err) => {
162
+ if (err.code === 'ECONNREFUSED') {
163
+ reject(new Error(`Could not connect to pnpr server at ${registryUrl}. Is the server running?`));
164
+ }
165
+ else {
166
+ reject(err);
167
+ }
168
+ });
169
+ req.write(body);
170
+ req.end();
171
+ });
172
+ }
173
+ export function writeRawIndexEntries(indexEntries, storeIndex) {
174
+ const writes = indexEntries.filter(({ key }) => !storeIndex.has(key));
175
+ if (writes.length > 0) {
176
+ storeIndex.setRawMany(writes);
177
+ }
178
+ }
179
+ //# sourceMappingURL=fetchFromPnpmRegistry.js.map
package/lib/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { fetchFromPnpmRegistry, type FetchFromPnpmRegistryOptions, type FetchFromPnpmRegistryResult, writeRawIndexEntries } from './fetchFromPnpmRegistry.js';
2
+ export { type ResponseMetadata } from './protocol.js';
package/lib/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { fetchFromPnpmRegistry, writeRawIndexEntries } from './fetchFromPnpmRegistry.js';
2
+ export {} from './protocol.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ import type { LockfileObject } from '@pnpm/lockfile.types';
2
+ export interface ResponseMetadata {
3
+ lockfile: LockfileObject;
4
+ stats: {
5
+ totalPackages: number;
6
+ alreadyInStore: number;
7
+ packagesToFetch: number;
8
+ filesInNewPackages: number;
9
+ filesAlreadyInCafs: number;
10
+ filesToDownload: number;
11
+ downloadBytes: number;
12
+ };
13
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=protocol.js.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@pnpm/pnpr.client",
3
+ "version": "1.1.0",
4
+ "description": "Client for the pnpr server — sends store state, receives resolved lockfile and missing files",
5
+ "keywords": [
6
+ "pnpm",
7
+ "pnpm11"
8
+ ],
9
+ "license": "MIT",
10
+ "funding": "https://opencollective.com/pnpm",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/pnpm/pnpm/tree/main/pnpr/client"
14
+ },
15
+ "homepage": "https://github.com/pnpm/pnpm/tree/main/pnpr/client#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/pnpm/pnpm/issues"
18
+ },
19
+ "type": "module",
20
+ "main": "lib/index.js",
21
+ "types": "lib/index.d.ts",
22
+ "exports": {
23
+ ".": "./lib/index.js"
24
+ },
25
+ "files": [
26
+ "lib",
27
+ "!*.map"
28
+ ],
29
+ "dependencies": {
30
+ "@pnpm/store.index": "1100.1.0",
31
+ "@pnpm/lockfile.fs": "1100.1.3",
32
+ "@pnpm/store.cafs": "1100.1.8",
33
+ "@pnpm/lockfile.types": "1100.0.9"
34
+ },
35
+ "peerDependencies": {
36
+ "@pnpm/logger": "^1001.0.1",
37
+ "@pnpm/worker": "^1100.1.9"
38
+ },
39
+ "devDependencies": {
40
+ "@pnpm/types": "1101.3.0",
41
+ "@pnpm/pnpr.client": "1.1.0"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.13"
45
+ },
46
+ "jest": {
47
+ "preset": "@pnpm/jest-config"
48
+ },
49
+ "scripts": {
50
+ "start": "tsgo --watch",
51
+ "lint": "eslint \"src/**/*.ts\"",
52
+ "test": "pn compile",
53
+ "compile": "tsgo --build && pn lint --fix"
54
+ }
55
+ }