verdaccio
Version:
A lightweight private npm proxy registry
400 lines (399 loc) • 14.4 kB
JavaScript
import { API_ERROR, DIST_TAGS, HTTP_STATUS, STORAGE, USERS } from "./constants.mjs";
import { logger } from "./logger/index.mjs";
import { ErrorCode, isObject, normalizeDistTags } from "./utils.mjs";
import _ from "lodash";
import { pkgUtils } from "@verdaccio/core";
import { generateRandomHexString } from "@verdaccio/utils";
import createDebug from "debug";
import { SearchMemoryIndexer } from "@verdaccio/search-indexer";
//#region src/lib/storage-utils.ts
var debug = createDebug("verdaccio:storage-utils");
/**
* Create an empty package manifest with all the internal bookkeeping
* sections (`_uplinks`, `_distfiles`, `_attachments`, `_rev`) initialized.
* Used as the starting point when a package is seen for the first time.
* @param name package name
* @return an empty manifest for the given package
*/
function generatePackageTemplate(name) {
return {
name,
versions: {},
time: {},
[USERS]: {},
[DIST_TAGS]: {},
_uplinks: {},
_distfiles: {},
_attachments: {},
_rev: ""
};
}
/**
* Normalize a manifest read from storage: ensure the object sections
* (`versions`, `dist-tags`, `_distfiles`, `_attachments`, `_uplinks`, `time`)
* exist, default the revision and `_id`, and normalize the dist-tags shape.
* The manifest is mutated in place.
* @param pkg package manifest reference
* @return the same manifest, normalized
*/
function normalizePackage(pkg) {
[
"versions",
"dist-tags",
"_distfiles",
"_attachments",
"_uplinks",
"time"
].forEach((key) => {
const pkgProp = pkg[key];
if (_.isNil(pkgProp) || isObject(pkgProp) === false) pkg[key] = {};
});
if (_.isString(pkg._rev) === false) {
debug("package %o has no revision, setting default", pkg.name);
pkg._rev = STORAGE.DEFAULT_REVISION;
}
if (_.isString(pkg._id) === false) pkg._id = pkg.name;
normalizeDistTags(pkg);
return pkg;
}
/**
* Produce the next revision id in the `<counter>-<hash>` format used by the
* storage layer, incrementing the counter of the previous revision.
* @param rev previous revision id, eg. `2-a1b2c3`
* @return the next revision id
*/
function generateRevision(rev) {
return (+rev.split("-")[0] || 0) + 1 + "-" + generateRandomHexString();
}
/**
* Pick the most relevant readme for a package: the manifest readme or the
* `latest` version readme first, otherwise the first readme found across the
* `next`, `beta`, `alpha`, `test`, `dev` and `canary` dist-tags, in that order.
* @param pkg package manifest
* @return the readme content, or an empty string when none is available
*/
function getLatestReadme(pkg) {
const versions = pkg["versions"] || {};
const distTags = pkg["dist-tags"] || {};
const latestVersion = distTags["latest"] ? versions[distTags["latest"]] || {} : {};
let readme = _.trim(pkg.readme || latestVersion.readme || "");
if (readme) return readme;
[
"next",
"beta",
"alpha",
"test",
"dev",
"canary"
].map(function(tag) {
if (readme) return readme;
const version = distTags[tag] ? versions[distTags[tag]] || {} : {};
readme = _.trim(version.readme || readme);
});
return readme;
}
/**
* Remove the readme from a version object: only one readme is kept per
* package (see getLatestReadme), never one per version. The version is
* mutated in place.
* @param version version metadata
* @return the same version without its readme
*/
function cleanUpReadme(version) {
if (_.isNil(version) === false) delete version.readme;
return version;
}
/**
* Manifest properties allowed in responses served to clients; everything
* else is verdaccio-internal bookkeeping and is stripped by cleanUpLinksRef.
*/
var WHITELIST = [
"_rev",
"name",
"versions",
"dist-tags",
"readme",
"time",
"_id",
"users"
];
/**
* Strip verdaccio-internal sections (`_distfiles`, `_attachments`, ...) from
* a manifest before serving it to a client. The manifest is mutated in place.
* @param keepUpLinkData whether the `_uplinks` section should be preserved
* @param result manifest to clean up
* @return the same manifest with only whitelisted properties
*/
function cleanUpLinksRef(keepUpLinkData, result) {
const propertyToKeep = [...WHITELIST];
if (keepUpLinkData === true) propertyToKeep.push("_uplinks");
for (const i in result) if (propertyToKeep.indexOf(i) === -1) delete result[i];
return result;
}
/**
* Verify a package does not exist locally yet; part of the publish flow.
* @param name package name
* @param localStorage local storage instance
* @throws conflict error when the package already exists locally
*/
async function checkPackageLocal(name, localStorage) {
debug("checking if %o already exists locally", name);
try {
if (await localStorage.getPackageMetadataAsync(name)) {
debug("package %o already exists locally", name);
throw ErrorCode.getConflict(API_ERROR.PACKAGE_EXIST);
}
} catch (err) {
if (err.status === HTTP_STATUS.NOT_FOUND) {
debug("package %o does not exist locally", name);
return;
}
throw err;
}
}
/**
* Create a new package in the local storage and register its latest version
* in the in-memory search indexer.
* @param name package name
* @param metadata package manifest to store
* @param localStorage local storage instance
*/
function publishPackage(name, metadata, localStorage) {
debug("publishing a new package for %o", name);
return new Promise((resolve, reject) => {
localStorage.addPackage(name, metadata, (err, latest) => {
if (!_.isNull(err)) return reject(err);
else if (!_.isUndefined(latest)) SearchMemoryIndexer.add(latest).catch((reason) => {
debug("indexer has failed on add item %o", reason);
logger.error("indexer has failed on add item");
});
return resolve();
});
});
}
/**
* Verify a package does not exist on any uplink yet; part of the publish
* flow. Uplink failures other than 404 reject the publish, unless
* `publish.allow_offline` is enabled in the configuration.
* @param name package name
* @param isAllowPublishOffline whether publishing with unreachable uplinks is allowed
* @param syncMetadata bound Storage._syncUplinksMetadata function
* @throws conflict error when the package exists upstream, service unavailable when uplinks are offline
*/
function checkPackageRemote(name, isAllowPublishOffline, syncMetadata) {
debug("checking if %o already exists on uplinks", name);
return new Promise((resolve, reject) => {
syncMetadata(name, null, {}, (err, packageJsonLocal, upLinksErrors) => {
if (err && err.status !== HTTP_STATUS.NOT_FOUND) return reject(err);
if (_.isNil(packageJsonLocal) === false) {
debug("package %o already exists on an uplink", name);
return reject(ErrorCode.getConflict(API_ERROR.PACKAGE_EXIST));
}
for (let errorItem = 0; errorItem < upLinksErrors.length; errorItem++) if (_.isNil(upLinksErrors[errorItem][0]) === false) {
if (upLinksErrors[errorItem][0].status !== HTTP_STATUS.NOT_FOUND) {
if (isAllowPublishOffline) {
debug("uplink offline for %o, publish offline is allowed", name);
return resolve();
}
debug("uplink offline for %o, rejecting publish", name);
return reject(ErrorCode.getServiceUnavailable(API_ERROR.UPLINK_OFFLINE_PUBLISH));
}
}
return resolve();
});
});
}
/**
* Merge the `time` field of a remote manifest into the cached one; remote
* entries win on conflicts. Returns a new manifest, the inputs are untouched.
* @param cacheManifest local cached manifest
* @param remoteManifest manifest fetched from an uplink
* @return the cached manifest with the merged time field
*/
function mergeUplinkTimeIntoLocal(cacheManifest, remoteManifest) {
if ("time" in remoteManifest) {
debug("merging remote time field into local manifest for %o", cacheManifest.name);
return {
...cacheManifest,
time: {
...cacheManifest.time,
...remoteManifest.time
}
};
}
return cacheManifest;
}
/**
* Build the search item emitted for a local package during a search stream,
* based on its latest version: npm-search compatible maintainers
* (`{ username, email }`), publisher (from `_npmUser`, falling back to the
* first maintainer), license and links.
* @param data package manifest
* @param time modified time attached to the search item
* @return the search item, or undefined when the package has no usable latest version
*/
function prepareSearchPackage(data, time) {
const latest = pkgUtils.getLatest(data);
if (!latest || !data.versions[latest]) {
debug("no latest version found for %o, skipping search item", data.name);
return;
}
debug("preparing search item for %o@%o", data.name, latest);
if (latest && data.versions[latest]) {
const version = data.versions[latest];
const versions = { [latest]: "latest" };
const rawMaintainers = version.maintainers || [version.author].filter(Boolean);
const maintainers = Array.isArray(rawMaintainers) ? rawMaintainers.map((maintainer) => typeof maintainer === "string" ? {
username: maintainer,
email: ""
} : {
...maintainer,
username: maintainer?.username ?? maintainer?.name ?? "",
email: maintainer?.email ?? ""
}) : [];
const npmUser = version._npmUser;
const publisher = npmUser?.name ? {
username: npmUser.name,
email: npmUser.email ?? ""
} : maintainers[0] ?? {};
return {
name: version.name,
description: version.description,
[DIST_TAGS]: { latest },
maintainers,
publisher,
author: version.author,
repository: version.repository,
readmeFilename: version.readmeFilename || "",
homepage: version.homepage,
keywords: version.keywords,
time: { modified: time },
bugs: version.bugs,
license: version.license,
versions
};
}
}
/**
* Whether the tarball url path ends with the given file name
* (any query string or fragment is ignored, like the path parsing
* in updateVersions).
*/
function tarballMatchesFilename(tarball, filename) {
return tarball.split(/[?#]/)[0].endsWith(`/${filename}`);
}
/**
* Build a distfile record from a version's dist metadata when its tarball
* url matches the given file name.
*/
function distFileFromVersion(version, filename) {
const dist = version?.dist;
if (dist?.tarball && tarballMatchesFilename(dist.tarball, filename)) return {
url: dist.tarball,
sha: dist.shasum
};
return null;
}
/**
* Resolve the distfile record for a tarball filename. Prefers the
* `_distfiles` bookkeeping, but falls back to the version metadata when the
* record is missing (storages written by other verdaccio versions cache
* versions without their distfile records, which made those tarballs
* permanently unavailable).
* @param manifest cached manifest
* @param filename tarball file name, eg. pkg-1.0.0.tgz
*/
function lookupDistFile(manifest, filename) {
const distFile = manifest?._distfiles?.[filename];
if (_.isNil(distFile) === false) return distFile;
const versions = manifest?.versions ?? {};
const basename = manifest?.name?.replace(/^.*\//, "");
if (basename && filename.startsWith(`${basename}-`) && filename.endsWith(".tgz")) {
const versionId = filename.slice(basename.length + 1, -4);
const distFromVersion = distFileFromVersion(versions[versionId], filename);
if (distFromVersion) {
debug("distfile record missing for %o, using version %o dist", filename, versionId);
return distFromVersion;
}
}
for (const versionId in versions) {
const distFromVersion = distFileFromVersion(versions[versionId], filename);
if (distFromVersion) {
debug("distfile record missing for %o, using version %o dist", filename, versionId);
return distFromVersion;
}
}
debug("no distfile record or version dist found for %o", filename);
return null;
}
/**
* Check whether the package metadata has enough data to be published.
* @param pkg package manifest
* @return true when the manifest carries a versions section
*/
function isPublishablePackage(pkg) {
return Object.keys(pkg).includes("versions");
}
/**
* Whether a version declares an install lifecycle script (`install`,
* `preinstall` or `postinstall`); surfaced in abbreviated manifests as
* `hasInstallScript` so clients can warn about script execution.
* @param version version metadata
* @return true when an install script is present
*/
function hasInstallScript(version) {
if (version?.scripts) return Object.keys(version.scripts).find((item) => {
return [
"install",
"preinstall",
"postinstall"
].includes(item);
}) !== void 0;
return false;
}
/**
* Convert a full manifest into the abbreviated format served when the client
* requests `application/vnd.npm.install-v1+json` (npm install), keeping only
* the fields the npm registry contract defines for installation.
* https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md
* @param manifest full package manifest
* @return the abbreviated manifest
*/
function convertAbbreviatedManifest(manifest) {
if (debug.enabled) debug("converting %o to abbreviated manifest with %o versions", manifest.name, Object.keys(manifest.versions).length);
const abbreviatedVersions = Object.keys(manifest.versions).reduce((acc, version) => {
const _version = manifest.versions[version];
acc[version] = {
name: _version.name,
version: _version.version,
description: _version.description,
deprecated: _version.deprecated,
bin: _version.bin,
dist: _version.dist,
engines: _version.engines,
cpu: _version.cpu,
os: _version.os,
funding: _version.funding,
directories: _version.directories,
dependencies: _version.dependencies,
devDependencies: _version.devDependencies,
peerDependencies: _version.peerDependencies,
peerDependenciesMeta: _version.peerDependenciesMeta,
optionalDependencies: _version.optionalDependencies,
bundleDependencies: _version.bundleDependencies,
_hasShrinkwrap: _version._hasShrinkwrap,
hasInstallScript: hasInstallScript(_version)
};
return acc;
}, {});
return {
name: manifest["name"],
[DIST_TAGS]: manifest[DIST_TAGS],
versions: abbreviatedVersions,
modified: manifest?.time?.modified,
time: manifest?.time
};
}
//#endregion
export { WHITELIST, checkPackageLocal, checkPackageRemote, cleanUpLinksRef, cleanUpReadme, convertAbbreviatedManifest, distFileFromVersion, generatePackageTemplate, generateRevision, getLatestReadme, hasInstallScript, isPublishablePackage, lookupDistFile, mergeUplinkTimeIntoLocal, normalizePackage, prepareSearchPackage, publishPackage, tarballMatchesFilename };
//# sourceMappingURL=storage-utils.mjs.map