mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 18:05:29 -04:00
## What
Adds an opt-in `frozenStore` / `--frozen-store` setting (default `false`) that lets `pnpm install --offline --frozen-lockfile` run against a package store that lives on a **read-only filesystem** — a Nix store, a read-only bind mount, an OCI layer.
## Why
A normal install fails against such a store **not** because it writes package content, but because it unconditionally:
1. opens the SQLite `index.db` in WAL mode, which needs to create `-shm`/`-wal` sidecars in the store directory; and
2. writes a project-registry entry under the store.
Both fail with `attempt to write a readonly database` / `EROFS` on a read-only store directory, even when the store is complete and the lockfile is frozen. This blocks any deployment that wants an immutable, content-addressed store.
## How
When `frozenStore` is enabled, pnpm opens `index.db` through the SQLite **`immutable=1`** URI — which tells SQLite the file cannot change underneath it, so it bypasses the WAL/`-shm` sidecar machinery entirely and reads the raw file with zero sidecar creation — and suppresses every store-write path. Pair it with `--offline --frozen-lockfile` against a fully-populated store. It is incompatible with two settings that would write into the store, and each throws a clear config-conflict error before any network or store access: **`--force`** (which bypasses the no-write-on-hit skip → `ERR_PNPM_CONFIG_CONFLICT_FROZEN_STORE_WITH_FORCE`) and a configured **pnpr server** (which would fetch and write packages into the store → `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`).
> Plain `SQLITE_OPEN_READ_ONLY` is **not** sufficient: opening a WAL-mode db read-only still tries to create the `-shm` sidecar, which fails on a read-only *directory*. `immutable=1` is the load-bearing piece.
> **Node.js requirement:** `node:sqlite` only passes `SQLITE_OPEN_URI` to SQLite (so the `immutable=1` query is honored rather than treated as part of a literal filename) starting in **v22.15.0** (22.x line), **v23.11.0**, and every **v24+**. pnpm's `engines` floor is `>=22.13`, so on a runtime older than that the frozen open is detected up front and fails with a clear `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` instead of SQLite's cryptic "unable to open database file". (pacquet uses rusqlite with an explicit `SQLITE_OPEN_URI` flag, so it has no such floor.)
> The `immutable=1` URI path is also percent-encoded (`%`→`%25`, `?`→`%3f`, `#`→`%23`, in that order, leaving `/` literal) so a store path containing those characters doesn't truncate the path or inject a spurious query parameter — applied identically in both stacks.
### Build backstop under the global virtual store
Under the global virtual store (default), a package's directory lives **inside** the store (`{storeDir}/links/...`). Applying a patch or running an allowlisted lifecycle script writes into that directory — so on a frozen store it would crash mid-build with a raw `EROFS`. A fully-seeded store never reaches the build step (patched/built packages are imported from the side-effects cache and filtered out by the `isBuilt` gate), so any residual build candidate means the seed is **missing that package's build output**.
`buildModules` now refuses up front with `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` and actionable guidance ("rebuild the seed with their scripts enabled, or remove them from `onlyBuiltDependencies`") instead of failing cryptically once a script starts. The check is gated on the global virtual store — under the isolated linker, slot directories live in the writable project-local store, so builds there are fine. Non-allowlisted scripts never run, so they are not treated as a blocking write.
Bin-linking has its own read-only-store edge under the global virtual store. On a **warm** checkout (the project's `.bin/<name>` already points at the seed target) `linkBin` returns before touching the store, so it is write-free. But on a **fresh** checkout it (re)creates the bin and calls `fixBin`, whose `chmod` targets the bin's **source file inside the store** (`{storeDir}/links/...`) — which is refused with `EPERM`/`EACCES` on a read-only store, even though a complete seed already ships that bin executable (so the `chmod` is redundant). `@pnpm/bins.linker` now wraps that call in `ensureExecutable`: it swallows the refusal when the target is already executable and rethrows otherwise, so bin-linking is write-free against a frozen store on a cold checkout too, while a genuinely non-executable bin (a broken seed) still surfaces as an error.
The blocking predicate distinguishes the two write kinds: a **patch** is applied regardless of `ignoreScripts`, so a patched package is always blocked; a **lifecycle script** is suppressed under `ignoreScripts`, so an allowlisted build-requiring package is *not* blocked when scripts are off (it would write nothing). This avoids falsely rejecting a valid `--ignore-scripts` frozen install. **Optional dependencies are exempt**: a build or patch failure on an optional dependency is non-fatal at runtime, so a seed missing an optional package's build output skips that build (emitting the `skipped-optional-dependency` log) instead of blocking the install — in both stacks.
### Both stacks (parity rule)
**TypeScript pnpm CLI**
- Config plumbing (`@pnpm/config.reader`): `frozen-store` type, config-file key, default, `Config.frozenStore`.
- The read-only open branch (`@pnpm/store.index`): `immutable=1`, read-only statements, throwing mutators.
- Wiring through `@pnpm/store.controller` and `@pnpm/store.connection-manager` to the sole `StoreIndex` construction site.
- Gating the project-registry write (`@pnpm/installing.context`).
- The `--force` / pnpr-server conflict guards (`@pnpm/installing.deps-installer`) and CLI surface (`@pnpm/installing.commands`).
- **After-install rebuild** (`@pnpm/building.after-install`): the post-install rebuild opens its `StoreIndex` immutably under the flag, so re-reading the store for a rebuild never attempts a writable open against the frozen store.
- **Worker fix:** `@pnpm/worker` opens its *own* writable `StoreIndex` on every `readPkgFromCafs` cache hit, so a pure read crashed on a frozen store. `frozenStore` is threaded through to `getStoreIndex` and keyed into its connection cache.
- **Build backstop** (`@pnpm/building.during-install`): `buildModules` throws `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` for a GVS slot that would build/patch on a frozen store, honoring `ignoreScripts`; threaded from `@pnpm/installing.deps-installer`.
- **Bin-linking on a read-only store** (`@pnpm/bins.linker`): `linkBin` wraps the `fixBin` chmod in `ensureExecutable`, which tolerates `EPERM`/`EACCES` when the bin's store-resident source is already executable (a complete seed) and rethrows otherwise — so a fresh checkout against a frozen store links bins without crashing on the redundant chmod. Catch-on-failure keeps the writable hot path at zero added syscalls.
**Rust pacquet**
- A dedicated `open_immutable` / `shared_immutable_in` opens via `immutable=1`, selected only under the flag. Plain `open_readonly` keeps the ordinary `SQLITE_OPEN_READ_ONLY` open (WAL locking intact) because normal installs read the index while the same process's `StoreIndexWriter` writes it concurrently — an immutable connection skips all locking and change detection, so a concurrent writer would make those reads undefined.
- `--frozen-store` CLI flag + `frozenStore` workspace-yaml setting.
- The store-index writer is replaced with a drain-and-drop stub (`spawn_disabled`) and `init_store_dir_best_effort` is skipped under the flag.
- **Build backstop:** `build_modules` returns `BuildModulesError::FrozenStoreNeedsBuild` (`ERR_PNPM_FROZEN_STORE_NEEDS_BUILD`) under the same GVS + frozen-store condition, threaded from `config.frozen_store`. The gate keys off `should_run_scripts` (which already folds the allow-build policy), so it is correct without an explicit ignore-scripts branch — pacquet has no configurable ignore-scripts mode yet.
pacquet already separated read-only index access (`shared_readonly_in`, or `shared_immutable_in` under the flag) from writes, so it never had the worker-conflation bug; the flag makes the "no writes attempted" contract explicit and gates the remaining best-effort write attempts.
## Testing
- **TS:** `store.index` frozen-mode-on-`0555`-directory test (reads work, writes throw `ERR_PNPM_FROZEN_STORE_WRITE`) plus a path-with-`?` open test — both gated on the runtime's immutable-URI support, with a complementary test asserting `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` fires where that support is absent (the CI Node 22.13.0 path); `config.reader` round-trip; `deps-installer` `--force` and pnpr-server conflict guards; `worker`/`package-requester` unit tests. End-to-end on a `chmod -R 0555` store: install succeeds, `node_modules` materializes, no `-shm`/`-wal`/`-journal` sidecars; negative control without the flag fails as expected; incomplete store → clean offline error.
- **pacquet:** `open_immutable_reads_wal_db_on_readonly_directory` unit test plus the `immutable_sqlite_uri` encoding test and a path-with-`?` open test; yaml + CLI fold tests; **integration test** `frozen_store_installs_against_a_read_only_store` — primes a store, `chmod 0555` the tree, runs `install --frozen-lockfile --frozen-store --offline`, asserts success + materialized `node_modules` + zero sidecars. Confirmed load-bearing by reverting the `immutable=1` fix (test then fails).
- **Build backstop (both stacks):** `building/during-install` unit tests — approved-build-not-cached and patched-not-cached refuse; cached, non-allowlisted, and non-GVS cases pass through; **approved-build-under-`ignoreScripts` passes through while patched-under-`ignoreScripts` still refuses** — and the matching pacquet `build_modules` tests (`frozen_store_gvs_patch_not_seeded_refuses` + GVS-off / frozen-off controls). Each confirmed load-bearing by disabling the relevant guard and watching the corresponding test fail.
- **Bin-linking (`bins/linker`):** `ensureExecutable` tests with `fixBin` mocked to reject with `EPERM` — an already-executable bin source resolves (and `fixBin` is asserted called, so it isn't the warm skip-guard passing), a non-executable one rethrows `EPERM`. Confirmed end-to-end by running the built `linkBins` against a real `chflags uchg`-immutable store: the executable-seed case resolves and links the bin, the non-executable-seed control throws `EPERM`.
A changeset is included with `"pnpm": minor` and `"@pnpm/bins.linker": patch` (the read-only-store bin-linking fix).
671 lines
22 KiB
TypeScript
671 lines
22 KiB
TypeScript
import { createReadStream, promises as fs } from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import { packageIsInstallable } from '@pnpm/config.package-is-installable'
|
|
import { fetchingProgressLogger, progressLogger } from '@pnpm/core-loggers'
|
|
import { depPathToFilename } from '@pnpm/deps.path'
|
|
import { PnpmError } from '@pnpm/error'
|
|
import type {
|
|
DirectoryFetcherResult,
|
|
Fetchers,
|
|
FetchOptions,
|
|
FetchResult,
|
|
} from '@pnpm/fetching.fetcher-base'
|
|
import { pickFetcher } from '@pnpm/fetching.pick-fetcher'
|
|
import gfs from '@pnpm/fs.graceful-fs'
|
|
import type { CustomFetcher } from '@pnpm/hooks.types'
|
|
import { logger } from '@pnpm/logger'
|
|
import {
|
|
type AtomicResolution,
|
|
type DirectoryResolution,
|
|
type PlatformAssetResolution,
|
|
type PreferredVersions,
|
|
type Resolution,
|
|
type ResolveFunction,
|
|
resolvePlatformSelector,
|
|
type ResolveResult,
|
|
selectPlatformVariant,
|
|
type TarballResolution,
|
|
} from '@pnpm/resolving.resolver-base'
|
|
import {
|
|
normalizeBundledManifest,
|
|
} from '@pnpm/store.cafs'
|
|
import type { Cafs } from '@pnpm/store.cafs-types'
|
|
import type {
|
|
BundledManifest,
|
|
FetchPackageToStoreFunction,
|
|
FetchPackageToStoreOptions,
|
|
GetFilesIndexFilePath,
|
|
PackageResponse,
|
|
PkgNameVersion,
|
|
PkgRequestFetchResult,
|
|
RequestPackageFunction,
|
|
RequestPackageOptions,
|
|
WantedDependency,
|
|
} from '@pnpm/store.controller-types'
|
|
import { pickStoreIndexKey } from '@pnpm/store.index'
|
|
import type { DependencyManifest, SupportedArchitectures } from '@pnpm/types'
|
|
import {
|
|
calcMaxWorkers,
|
|
readPkgFromCafs as _readPkgFromCafs,
|
|
type ReadPkgFromCafsOptions,
|
|
type ReadPkgFromCafsResult,
|
|
} from '@pnpm/worker'
|
|
import { familySync } from 'detect-libc'
|
|
import { loadJsonFile } from 'load-json-file'
|
|
import pDefer, { type DeferredPromise } from 'p-defer'
|
|
import PQueue from 'p-queue'
|
|
import { pShare } from 'promise-share'
|
|
import { pick } from 'ramda'
|
|
import ssri from 'ssri'
|
|
|
|
let currentLibc: 'glibc' | 'musl' | undefined | null
|
|
function getLibcFamilySync () {
|
|
if (currentLibc === undefined) {
|
|
currentLibc = familySync() as unknown as typeof currentLibc
|
|
}
|
|
return currentLibc
|
|
}
|
|
const TARBALL_INTEGRITY_FILENAME = 'tarball-integrity'
|
|
const packageRequestLogger = logger('package-requester')
|
|
|
|
|
|
export function createPackageRequester (
|
|
opts: {
|
|
engineStrict?: boolean
|
|
force?: boolean
|
|
nodeVersion?: string
|
|
pnpmVersion?: string
|
|
resolve: ResolveFunction
|
|
fetchers: Fetchers
|
|
cafs: Cafs
|
|
ignoreFile?: (filename: string) => boolean
|
|
networkConcurrency?: number
|
|
storeDir: string
|
|
verifyStoreIntegrity: boolean
|
|
virtualStoreDirMaxLength: number
|
|
strictStorePkgContentCheck?: boolean
|
|
customFetchers?: CustomFetcher[]
|
|
frozenStore?: boolean
|
|
}
|
|
): RequestPackageFunction & {
|
|
fetchPackageToStore: FetchPackageToStoreFunction
|
|
getFilesIndexFilePath: GetFilesIndexFilePath
|
|
requestPackage: RequestPackageFunction
|
|
} {
|
|
opts = opts || {}
|
|
|
|
// Downloads are I/O-bound, not CPU-bound: a low-latency registry only
|
|
// saturates with enough requests in flight, so the floor matters more
|
|
// than the per-core scaling — a CPU-derived floor left 4-core CI
|
|
// runners draining multi-hundred-tarball installs 16 at a time.
|
|
const networkConcurrency = opts.networkConcurrency ?? Math.min(96, Math.max(calcMaxWorkers() * 3, 64))
|
|
const requestsQueue = new PQueue({
|
|
concurrency: networkConcurrency,
|
|
})
|
|
|
|
const fetch = fetcher.bind(null, opts.fetchers, opts.cafs, opts.customFetchers)
|
|
const readPkgFromCafs = _readPkgFromCafs.bind(null, {
|
|
storeDir: opts.storeDir,
|
|
verifyStoreIntegrity: opts.verifyStoreIntegrity,
|
|
strictStorePkgContentCheck: opts.strictStorePkgContentCheck,
|
|
frozenStore: opts.frozenStore,
|
|
})
|
|
const fetchPackageToStore = fetchToStore.bind(null, {
|
|
readPkgFromCafs,
|
|
fetch,
|
|
fetchingLocker: new Map(),
|
|
requestsQueue: Object.assign(requestsQueue, {
|
|
counter: 0,
|
|
concurrency: networkConcurrency,
|
|
}),
|
|
storeDir: opts.storeDir,
|
|
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
|
|
strictStorePkgContentCheck: opts.strictStorePkgContentCheck,
|
|
})
|
|
const requestPackage = resolveAndFetch.bind(null, {
|
|
engineStrict: opts.engineStrict,
|
|
nodeVersion: opts.nodeVersion,
|
|
pnpmVersion: opts.pnpmVersion,
|
|
force: opts.force,
|
|
fetchPackageToStore,
|
|
requestsQueue,
|
|
resolve: opts.resolve,
|
|
storeDir: opts.storeDir,
|
|
})
|
|
|
|
return Object.assign(requestPackage, {
|
|
fetchPackageToStore,
|
|
getFilesIndexFilePath: getFilesIndexFilePath.bind(null, {
|
|
storeDir: opts.storeDir,
|
|
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
|
|
}),
|
|
requestPackage,
|
|
})
|
|
}
|
|
|
|
async function resolveAndFetch (
|
|
ctx: {
|
|
engineStrict?: boolean
|
|
force?: boolean
|
|
nodeVersion?: string
|
|
pnpmVersion?: string
|
|
requestsQueue: { add: <T>(fn: () => Promise<T>, opts: { priority: number }) => Promise<T> }
|
|
resolve: ResolveFunction
|
|
fetchPackageToStore: FetchPackageToStoreFunction
|
|
storeDir: string
|
|
},
|
|
wantedDependency: WantedDependency & { optional?: boolean },
|
|
options: RequestPackageOptions
|
|
): Promise<PackageResponse> {
|
|
let resolution = options.currentPkg?.resolution as Resolution
|
|
let pkgId = options.currentPkg?.id
|
|
|
|
// When we have a currentPkg but a resolution is still performed due to
|
|
// options.skipFetch, it's necessary to make sure the resolution doesn't
|
|
// accidentally return a newer version of the package. When skipFetch is
|
|
// set, the resolved package shouldn't be different. This is done by
|
|
// overriding the preferredVersions object to only contain the current
|
|
// package's version.
|
|
//
|
|
// A naive approach would be to change the bare specifier to be the exact
|
|
// version of the current pkg if the bare specifier is a range, but this
|
|
// would cause the version returned for calcSpecifier to be different.
|
|
const preferredVersions: PreferredVersions = (resolution && !options.update && options.currentPkg?.name != null && options.currentPkg?.version != null)
|
|
? {
|
|
...options.preferredVersions,
|
|
[options.currentPkg.name]: { [options.currentPkg.version]: 'version' },
|
|
}
|
|
: options.preferredVersions
|
|
|
|
const resolveResult = await ctx.requestsQueue.add<ResolveResult>(async () => ctx.resolve(wantedDependency, {
|
|
...options,
|
|
preferredVersions,
|
|
currentPkg: (options.currentPkg?.id && options.currentPkg?.resolution)
|
|
? {
|
|
id: options.currentPkg.id,
|
|
name: options.currentPkg.name,
|
|
version: options.currentPkg.version,
|
|
resolution: options.currentPkg.resolution,
|
|
publishedAt: options.currentPkg.publishedAt,
|
|
}
|
|
: undefined,
|
|
}), { priority: options.downloadPriority })
|
|
|
|
let { manifest } = resolveResult
|
|
const {
|
|
latest,
|
|
resolvedVia,
|
|
publishedAt,
|
|
normalizedBareSpecifier,
|
|
alias,
|
|
policyViolation,
|
|
} = resolveResult
|
|
|
|
// Check if the integrity has changed between the current and newly resolved package
|
|
// Use 'in' check to safely access integrity from any resolution type that has it
|
|
const previousResolution = options.currentPkg?.resolution
|
|
const previousIntegrity = previousResolution && 'integrity' in previousResolution ? previousResolution.integrity : undefined
|
|
const newIntegrity = 'integrity' in resolveResult.resolution ? resolveResult.resolution.integrity : undefined
|
|
const integrityChanged = previousIntegrity != null && newIntegrity != null && previousIntegrity !== newIntegrity
|
|
|
|
const updated = pkgId !== resolveResult.id || !resolution || integrityChanged
|
|
resolution = resolveResult.resolution
|
|
pkgId = resolveResult.id
|
|
|
|
// URL/tarball resolvers don't return an integrity, because it is only known
|
|
// after the tarball is downloaded. When a package is reused from the lockfile
|
|
// without being re-fetched, the freshly resolved resolution has no integrity,
|
|
// so carry it over from the current resolution instead of dropping it.
|
|
// https://github.com/pnpm/pnpm/issues/12001
|
|
if (
|
|
!updated &&
|
|
typeof previousIntegrity === 'string' &&
|
|
!resolution.type &&
|
|
!(resolution as TarballResolution).integrity
|
|
) {
|
|
(resolution as TarballResolution).integrity = previousIntegrity
|
|
}
|
|
|
|
const id = pkgId!
|
|
|
|
if ('type' in resolution && resolution.type === 'directory' && !id.startsWith('file:')) {
|
|
if (manifest == null) {
|
|
throw new Error(`Couldn't read package.json of local dependency ${wantedDependency.alias ? wantedDependency.alias + '@' : ''}${wantedDependency.bareSpecifier ?? ''}`)
|
|
}
|
|
return {
|
|
body: {
|
|
id,
|
|
isLocal: true,
|
|
manifest,
|
|
resolution: resolution as DirectoryResolution,
|
|
resolvedVia,
|
|
updated,
|
|
normalizedBareSpecifier,
|
|
alias,
|
|
},
|
|
}
|
|
}
|
|
|
|
let isInstallable: boolean | null | undefined = (
|
|
ctx.force === true ||
|
|
(
|
|
manifest == null
|
|
? undefined
|
|
: packageIsInstallable(id, manifest, {
|
|
engineStrict: ctx.engineStrict,
|
|
lockfileDir: options.lockfileDir,
|
|
nodeVersion: ctx.nodeVersion,
|
|
optional: wantedDependency.optional === true,
|
|
supportedArchitectures: options.supportedArchitectures,
|
|
})
|
|
)
|
|
)
|
|
// We can skip fetching the package only if the manifest
|
|
// is present after resolution AND the content of the package has not changed
|
|
if ((options.skipFetch === true || isInstallable === false) && !integrityChanged && (manifest != null)) {
|
|
return {
|
|
body: {
|
|
id,
|
|
isLocal: false as const,
|
|
isInstallable: isInstallable ?? undefined,
|
|
latest,
|
|
manifest,
|
|
normalizedBareSpecifier,
|
|
resolution,
|
|
resolvedVia,
|
|
updated,
|
|
publishedAt,
|
|
alias,
|
|
policyViolation,
|
|
},
|
|
}
|
|
}
|
|
|
|
const pkg: PkgNameVersion = manifest != null ? pick(['name', 'version'], manifest) : {}
|
|
const fetchResult = ctx.fetchPackageToStore({
|
|
allowBuild: options.allowBuild,
|
|
fetchRawManifest: true,
|
|
force: integrityChanged,
|
|
ignoreScripts: options.ignoreScripts,
|
|
lockfileDir: options.lockfileDir,
|
|
pkg: {
|
|
...(options.expectedPkg?.name != null
|
|
? (updated ? { name: options.expectedPkg.name, version: pkg.version } : options.expectedPkg)
|
|
: pkg
|
|
),
|
|
id,
|
|
resolution,
|
|
},
|
|
onFetchError: options.onFetchError,
|
|
supportedArchitectures: options.supportedArchitectures,
|
|
})
|
|
|
|
if (!manifest) {
|
|
const fetchedResult = await fetchResult.fetching()
|
|
if (fetchedResult.bundledManifest) {
|
|
manifest = fetchedResult.bundledManifest as DependencyManifest
|
|
} else if (fetchedResult.files.filesMap.has('package.json')) {
|
|
const loadedManifest = await loadJsonFile<Record<string, unknown>>(fetchedResult.files.filesMap.get('package.json')!)
|
|
// Skip placeholder package.json added as a completion marker by the worker
|
|
// for packages that genuinely lack one.
|
|
if (!loadedManifest._pnpmPlaceholder) {
|
|
manifest = loadedManifest as unknown as DependencyManifest
|
|
}
|
|
}
|
|
// Add integrity to resolution if it was computed during fetching (only for TarballResolution)
|
|
if (fetchedResult.integrity && !resolution.type && !(resolution as TarballResolution).integrity) {
|
|
(resolution as TarballResolution).integrity = fetchedResult.integrity
|
|
}
|
|
}
|
|
// Check installability now that we have the manifest (for git/tarball packages without registry metadata)
|
|
if (isInstallable === undefined && manifest != null) {
|
|
isInstallable = ctx.force === true || packageIsInstallable(id, manifest, {
|
|
engineStrict: ctx.engineStrict,
|
|
lockfileDir: options.lockfileDir,
|
|
nodeVersion: ctx.nodeVersion,
|
|
optional: wantedDependency.optional === true,
|
|
supportedArchitectures: options.supportedArchitectures,
|
|
})
|
|
}
|
|
return {
|
|
body: {
|
|
id,
|
|
isLocal: false as const,
|
|
isInstallable: isInstallable ?? undefined,
|
|
latest,
|
|
manifest,
|
|
normalizedBareSpecifier,
|
|
resolution,
|
|
resolvedVia,
|
|
updated,
|
|
publishedAt,
|
|
alias,
|
|
policyViolation,
|
|
},
|
|
fetching: fetchResult.fetching,
|
|
filesIndexFile: fetchResult.filesIndexFile,
|
|
}
|
|
}
|
|
|
|
interface FetchLock {
|
|
fetching: Promise<PkgRequestFetchResult>
|
|
filesIndexFile: string
|
|
fetchRawManifest?: boolean
|
|
}
|
|
|
|
interface GetFilesIndexFilePathResult {
|
|
target: string
|
|
filesIndexFile: string
|
|
resolution: AtomicResolution
|
|
}
|
|
|
|
function getFilesIndexFilePath (
|
|
ctx: {
|
|
storeDir: string
|
|
virtualStoreDirMaxLength: number
|
|
},
|
|
opts: Pick<FetchPackageToStoreOptions, 'pkg' | 'ignoreScripts' | 'supportedArchitectures'>
|
|
): GetFilesIndexFilePathResult {
|
|
const targetRelative = depPathToFilename(opts.pkg.id, ctx.virtualStoreDirMaxLength)
|
|
const target = path.join(ctx.storeDir, targetRelative)
|
|
const built = !opts.ignoreScripts
|
|
let resolution: AtomicResolution
|
|
if (opts.pkg.resolution.type === 'variations') {
|
|
resolution = findResolution(opts.pkg.resolution.variants, opts.supportedArchitectures)
|
|
} else {
|
|
resolution = opts.pkg.resolution
|
|
}
|
|
return {
|
|
target,
|
|
filesIndexFile: pickStoreIndexKey(resolution as TarballResolution, opts.pkg.id, { built }),
|
|
resolution,
|
|
}
|
|
}
|
|
|
|
function findResolution (resolutionVariants: PlatformAssetResolution[], supportedArchitectures?: SupportedArchitectures): AtomicResolution {
|
|
const selector = resolvePlatformSelector(supportedArchitectures, {
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
libc: getLibcFamilySync(),
|
|
})
|
|
const variant = selectPlatformVariant(resolutionVariants, selector)
|
|
if (!variant) {
|
|
const resolutionTargets = resolutionVariants.map((variant) => variant.targets)
|
|
throw new PnpmError('NO_RESOLUTION_MATCHED', `Cannot find a resolution variant for the current platform in these resolutions: ${JSON.stringify(resolutionTargets)}`)
|
|
}
|
|
return variant.resolution
|
|
}
|
|
|
|
function fetchToStore (
|
|
ctx: {
|
|
readPkgFromCafs: (
|
|
filesIndexFile: string,
|
|
opts?: ReadPkgFromCafsOptions
|
|
) => Promise<ReadPkgFromCafsResult>
|
|
fetch: (
|
|
packageId: string,
|
|
resolution: AtomicResolution,
|
|
opts: FetchOptions
|
|
) => Promise<FetchResult>
|
|
fetchingLocker: Map<string, FetchLock>
|
|
requestsQueue: {
|
|
add: <T>(fn: () => Promise<T>, opts: { priority: number }) => Promise<T>
|
|
counter: number
|
|
concurrency: number
|
|
}
|
|
storeDir: string
|
|
virtualStoreDirMaxLength: number
|
|
strictStorePkgContentCheck?: boolean
|
|
},
|
|
opts: FetchPackageToStoreOptions
|
|
): {
|
|
filesIndexFile: string
|
|
fetching: () => Promise<PkgRequestFetchResult>
|
|
} {
|
|
if (!opts.pkg.name) {
|
|
opts.fetchRawManifest = true
|
|
}
|
|
|
|
if (!ctx.fetchingLocker.has(opts.pkg.id)) {
|
|
const fetching = pDefer<PkgRequestFetchResult>()
|
|
const { filesIndexFile, target, resolution } = getFilesIndexFilePath(ctx, opts)
|
|
|
|
doFetchToStore(filesIndexFile, fetching, target, resolution)
|
|
|
|
ctx.fetchingLocker.set(opts.pkg.id, {
|
|
fetching: removeKeyOnFail(fetching.promise),
|
|
filesIndexFile,
|
|
fetchRawManifest: opts.fetchRawManifest,
|
|
})
|
|
|
|
// When files resolves, the cached result has to set fromStore to true, without
|
|
// affecting previous invocations: so we need to replace the cache.
|
|
//
|
|
// Changing the value of fromStore is needed for correct reporting of `pnpm server`.
|
|
// Otherwise, if a package was not in store when the server started, it will always be
|
|
// reported as "downloaded" instead of "reused".
|
|
fetching.promise.then((cache) => {
|
|
progressLogger.debug({
|
|
packageId: opts.pkg.id,
|
|
requester: opts.lockfileDir,
|
|
status: cache.files.resolvedFrom === 'remote'
|
|
? 'fetched'
|
|
: 'found_in_store',
|
|
})
|
|
|
|
// If it's already in the store, we don't need to update the cache
|
|
if (cache.files.resolvedFrom !== 'remote') {
|
|
return
|
|
}
|
|
|
|
const tmp = ctx.fetchingLocker.get(opts.pkg.id)
|
|
|
|
// If fetching failed then it was removed from the cache.
|
|
// It is OK. In that case there is no need to update it.
|
|
if (tmp == null) return
|
|
|
|
ctx.fetchingLocker.set(opts.pkg.id, {
|
|
...tmp,
|
|
fetching: Promise.resolve({
|
|
...cache,
|
|
files: {
|
|
...cache.files,
|
|
resolvedFrom: 'store',
|
|
},
|
|
}),
|
|
})
|
|
})
|
|
.catch(() => {
|
|
ctx.fetchingLocker.delete(opts.pkg.id)
|
|
})
|
|
}
|
|
|
|
const result = ctx.fetchingLocker.get(opts.pkg.id)!
|
|
|
|
if (opts.fetchRawManifest && !result.fetchRawManifest) {
|
|
result.fetching = removeKeyOnFail(
|
|
result.fetching.then(async ({ files }) => {
|
|
if (!files.filesMap.has('package.json')) return {
|
|
files,
|
|
bundledManifest: undefined,
|
|
}
|
|
return {
|
|
files,
|
|
bundledManifest: await readBundledManifest(files.filesMap.get('package.json')!),
|
|
}
|
|
})
|
|
)
|
|
result.fetchRawManifest = true
|
|
}
|
|
|
|
return {
|
|
fetching: pShare(result.fetching),
|
|
filesIndexFile: result.filesIndexFile,
|
|
}
|
|
|
|
async function removeKeyOnFail<T> (p: Promise<T>): Promise<T> {
|
|
try {
|
|
return await p
|
|
} catch (err: any) { // eslint-disable-line
|
|
ctx.fetchingLocker.delete(opts.pkg.id)
|
|
if (opts.onFetchError) {
|
|
throw opts.onFetchError(err)
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
async function doFetchToStore (
|
|
filesIndexFile: string,
|
|
fetching: DeferredPromise<PkgRequestFetchResult>,
|
|
target: string,
|
|
resolution: AtomicResolution
|
|
) {
|
|
try {
|
|
const isLocalTarballDep = opts.pkg.id.startsWith('file:')
|
|
const isLocalPkg = resolution.type === 'directory'
|
|
|
|
if (
|
|
!opts.force &&
|
|
(
|
|
!isLocalTarballDep ||
|
|
await tarballIsUpToDate(opts.pkg.resolution as any, target, opts.lockfileDir) // eslint-disable-line
|
|
) &&
|
|
!isLocalPkg
|
|
) {
|
|
const { verified, files, bundledManifest } = await ctx.readPkgFromCafs(filesIndexFile, {
|
|
readManifest: opts.fetchRawManifest,
|
|
expectedPkg: opts.pkg,
|
|
})
|
|
if (verified) {
|
|
fetching.resolve({
|
|
files,
|
|
bundledManifest,
|
|
})
|
|
return
|
|
}
|
|
if ((files?.filesMap) != null) {
|
|
packageRequestLogger.warn({
|
|
message: `Refetching ${target} to store. It was either modified or had no integrity checksums`,
|
|
prefix: opts.lockfileDir,
|
|
})
|
|
}
|
|
}
|
|
|
|
// We fetch into targetStage directory first and then fs.rename() it to the
|
|
// target directory.
|
|
|
|
// Tarballs are requested first because they are bigger than metadata files.
|
|
// However, when one line is left available, allow it to be picked up by a metadata request.
|
|
// This is done in order to avoid situations when tarballs are downloaded in chunks
|
|
// As many tarballs should be downloaded simultaneously as possible.
|
|
const priority = (++ctx.requestsQueue.counter % ctx.requestsQueue.concurrency === 0 ? -1 : 1) * 1000
|
|
|
|
const fetchedPackage = await ctx.requestsQueue.add(async () => ctx.fetch(
|
|
opts.pkg.id,
|
|
resolution,
|
|
{
|
|
allowBuild: opts.allowBuild,
|
|
filesIndexFile,
|
|
lockfileDir: opts.lockfileDir,
|
|
readManifest: opts.fetchRawManifest,
|
|
onProgress: (downloaded) => {
|
|
fetchingProgressLogger.debug({
|
|
downloaded,
|
|
packageId: opts.pkg.id,
|
|
status: 'in_progress',
|
|
})
|
|
},
|
|
onStart: (size, attempt) => {
|
|
fetchingProgressLogger.debug({
|
|
attempt,
|
|
packageId: opts.pkg.id,
|
|
size,
|
|
status: 'started',
|
|
})
|
|
},
|
|
pkg: {
|
|
name: opts.pkg.name,
|
|
version: opts.pkg.version,
|
|
},
|
|
}
|
|
), { priority })
|
|
|
|
const integrity = (opts.pkg.resolution as TarballResolution).integrity ?? fetchedPackage.integrity
|
|
if (isLocalTarballDep && integrity) {
|
|
await fs.mkdir(target, { recursive: true })
|
|
await gfs.writeFile(path.join(target, TARBALL_INTEGRITY_FILENAME), integrity, 'utf8')
|
|
}
|
|
|
|
fetching.resolve({
|
|
files: {
|
|
resolvedFrom: fetchedPackage.local ? 'local-dir' : 'remote',
|
|
filesMap: fetchedPackage.filesMap,
|
|
packageImportMethod: (fetchedPackage as DirectoryFetcherResult).packageImportMethod,
|
|
requiresBuild: fetchedPackage.requiresBuild,
|
|
},
|
|
bundledManifest: fetchedPackage.manifest,
|
|
integrity,
|
|
})
|
|
} catch (err: any) { // eslint-disable-line
|
|
fetching.reject(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readBundledManifest (pkgJsonPath: string): Promise<BundledManifest | undefined> {
|
|
return normalizeBundledManifest(await loadJsonFile<DependencyManifest>(pkgJsonPath))
|
|
}
|
|
|
|
async function tarballIsUpToDate (
|
|
resolution: {
|
|
integrity?: string
|
|
registry?: string
|
|
tarball: string
|
|
},
|
|
pkgInStoreLocation: string,
|
|
lockfileDir: string
|
|
) {
|
|
let currentIntegrity!: string
|
|
try {
|
|
currentIntegrity = (await gfs.readFile(path.join(pkgInStoreLocation, TARBALL_INTEGRITY_FILENAME), 'utf8'))
|
|
} catch (err: any) { // eslint-disable-line
|
|
return false
|
|
}
|
|
if (resolution.integrity && currentIntegrity !== resolution.integrity) return false
|
|
|
|
const tarball = path.join(lockfileDir, resolution.tarball.slice(5))
|
|
const tarballStream = createReadStream(tarball)
|
|
try {
|
|
return Boolean(await ssri.checkStream(tarballStream, currentIntegrity))
|
|
} catch (err: any) { // eslint-disable-line
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function fetcher (
|
|
fetcherByHostingType: Fetchers,
|
|
cafs: Cafs,
|
|
customFetchers: CustomFetcher[] | undefined,
|
|
packageId: string,
|
|
resolution: AtomicResolution,
|
|
opts: FetchOptions
|
|
): Promise<FetchResult> {
|
|
try {
|
|
// pickFetcher now handles custom fetcher hooks internally
|
|
const fetch = await pickFetcher(fetcherByHostingType, resolution, {
|
|
customFetchers,
|
|
packageId,
|
|
})
|
|
const result = await fetch(cafs, resolution as any, opts) // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
return result
|
|
} catch (err: any) { // eslint-disable-line
|
|
packageRequestLogger.warn({
|
|
message: `Fetching ${packageId} failed!`,
|
|
prefix: opts.lockfileDir,
|
|
})
|
|
throw err
|
|
}
|
|
}
|