Don't cache packages on the filesystem

Should help with transient errors reported in #86, #104, and #110
This commit is contained in:
Michael Jackson 2018-07-04 16:30:11 -07:00
parent 700bb109a1
commit 5969ecc6ef
23 changed files with 570 additions and 702 deletions

View File

@ -21,14 +21,11 @@
"isomorphic-fetch": "^2.2.1",
"jsonwebtoken": "^8.1.0",
"mime": "^1.4.0",
"mkdirp": "^0.5.1",
"morgan": "^1.8.1",
"ndjson": "^1.5.0",
"node-forge": "^0.7.1",
"os-tmpdir": "^1.0.2",
"pretty-bytes": "^3",
"prop-types": "^15.5.8",
"proper-lockfile": "^3.0.2",
"raven": "^2.6.3",
"react": "^15.5.4",
"react-dom": "^15.5.4",
@ -36,8 +33,9 @@
"react-router-dom": "^4.0.0",
"redis": "^2.7.1",
"semver": "^5.3.0",
"sort-by": "^1.2.0",
"sri-toolbox": "^0.2.0",
"tar-fs": "^1.16.2",
"tar-stream": "^1.6.1",
"throng": "^4.0.0",
"validate-npm-package-name": "^3.0.0",
"warning": "^3.0.0",

View File

@ -5,51 +5,55 @@ const babel = require("babel-core");
const IndexPage = require("../components/IndexPage");
const unpkgRewrite = require("../plugins/unpkgRewrite");
const addLeadingSlash = require("../utils/addLeadingSlash");
const renderPage = require("../utils/renderPage");
const getMetadata = require("../utils/getMetadata");
const getFileContentType = require("../utils/getFileContentType");
const getEntries = require("../utils/getEntries");
/**
* Automatically generate HTML pages that show package contents.
*/
const AutoIndex = !process.env.DISABLE_INDEX;
/**
* Maximum recursion depth for meta listings.
*/
const MaximumDepth = 128;
function serveMetadata(req, res) {
getMetadata(
req.packageDir,
req.filename,
req.stats,
MaximumDepth,
(error, metadata) => {
if (error) {
console.error(error);
res
.status(500)
.type("text")
.send(
`Cannot generate metadata for ${req.packageSpec}${req.filename}`
);
} else {
// Cache metadata for 1 year.
res
.set({
"Cache-Control": "public, max-age=31536000",
"Cache-Tag": "meta"
})
.send(metadata);
}
}
);
function getContentTypeHeader(type) {
return type === "application/javascript" ? type + "; charset=utf-8" : type;
}
function rewriteBareModuleIdentifiers(file, packageConfig, callback) {
function getMetadata(entry, entries) {
const metadata = Object.assign(
{
path: addLeadingSlash(entry.name)
},
entry.type === "file"
? {
type: entry.type,
contentType: entry.contentType,
integrity: entry.integrity,
lastModified: entry.lastModified,
size: entry.size
}
: {
type: entry.type
}
);
if (entry.type === "directory") {
metadata.files = Object.keys(entries)
.filter(
name =>
name !== entry.name && path.dirname(name) === (entry.name || ".")
)
.map(name => getMetadata(entries[name], entries));
}
return metadata;
}
function serveMetadata(req, res) {
const metadata = getMetadata(req.entry, req.entries);
res
.set({
"Cache-Control": "public,max-age=31536000", // 1 year
"Cache-Tag": "meta"
})
.send(metadata);
}
function rewriteBareModuleIdentifiers(code, packageConfig) {
const dependencies = Object.assign(
{},
packageConfig.peerDependencies,
@ -64,119 +68,89 @@ function rewriteBareModuleIdentifiers(file, packageConfig, callback) {
plugins: [unpkgRewrite(dependencies)]
};
babel.transformFile(file, options, (error, result) => {
callback(error, result && result.code);
});
return babel.transform(code, options).code;
}
function serveJavaScriptModule(req, res) {
if (getFileContentType(req.filename) !== "application/javascript") {
if (req.entry.contentType !== "application/javascript") {
return res
.status(403)
.type("text")
.send("?module mode is available only for JavaScript files");
}
const file = path.join(req.packageDir, req.filename);
try {
const code = rewriteBareModuleIdentifiers(
req.entry.content.toString("utf8"),
req.packageConfig
);
rewriteBareModuleIdentifiers(file, req.packageConfig, (error, code) => {
if (error) {
console.error(error);
res
.set({
"Content-Length": Buffer.byteLength(code),
"Content-Type": getContentTypeHeader(req.entry.contentType),
"Cache-Control": "public,max-age=31536000", // 1 year
ETag: etag(code),
"Cache-Tag": "file,js-file,js-module"
})
.send(code);
} catch (error) {
console.error(error);
const errorName = error.constructor.name;
const errorMessage = error.message.replace(
/^.*?\/unpkg-.+?\//,
`/${req.packageSpec}/`
const errorName = error.constructor.name;
const errorMessage = error.message.replace(
/^.*?\/unpkg-.+?\//,
`/${req.packageSpec}/`
);
const codeFrame = error.codeFrame;
const debugInfo = `${errorName}: ${errorMessage}\n\n${codeFrame}`;
res
.status(500)
.type("text")
.send(
`Cannot generate module for ${req.packageSpec}${
req.filename
}\n\n${debugInfo}`
);
const codeFrame = error.codeFrame;
const debugInfo = `${errorName}: ${errorMessage}\n\n${codeFrame}`;
res
.status(500)
.type("text")
.send(
`Cannot generate module for ${req.packageSpec}${
req.filename
}\n\n${debugInfo}`
);
} else {
// Cache modules for 1 year.
res
.set({
"Content-Type": "application/javascript; charset=utf-8",
"Content-Length": Buffer.byteLength(code),
"Cache-Control": "public, max-age=31536000",
"Cache-Tag": "file,js-file,js-module"
})
.send(code);
}
});
}
}
function serveStaticFile(req, res) {
const tags = ["file"];
const ext = path.extname(req.filename).substr(1);
const ext = path.extname(req.entry.name).substr(1);
if (ext) {
tags.push(`${ext}-file`);
}
let contentType = getFileContentType(req.filename);
if (contentType === "application/javascript") {
contentType += "; charset=utf-8";
}
// Cache files for 1 year.
res.set({
"Content-Type": contentType,
"Content-Length": req.stats.size,
"Cache-Control": "public, max-age=31536000",
"Last-Modified": req.stats.mtime.toUTCString(),
ETag: etag(req.stats),
"Cache-Tag": tags.join(",")
});
const file = path.join(req.packageDir, req.filename);
const stream = fs.createReadStream(file);
stream.on("error", error => {
console.error(`Cannot send file ${req.packageSpec}${req.filename}`);
console.error(error);
res.sendStatus(500);
});
stream.pipe(res);
res
.set({
"Content-Length": req.entry.size,
"Content-Type": getContentTypeHeader(req.entry.contentType),
"Cache-Control": "public,max-age=31536000", // 1 year
"Last-Modified": req.entry.lastModified,
ETag: etag(req.entry.content),
"Cache-Tag": tags.join(",")
})
.send(req.entry.content);
}
function serveIndex(req, res) {
const dir = path.join(req.packageDir, req.filename);
const html = renderPage(IndexPage, {
packageInfo: req.packageInfo,
version: req.packageVersion,
filename: req.filename,
entries: req.entries,
entry: req.entry
});
getEntries(dir).then(
entries => {
const html = renderPage(IndexPage, {
packageInfo: req.packageInfo,
version: req.packageVersion,
dir: req.filename,
entries
});
// Cache HTML directory listings for 1 minute.
res
.set({
"Cache-Control": "public, max-age=60",
"Cache-Tag": "index"
})
.send(html);
},
error => {
console.error(error);
res
.status(500)
.type("text")
.send(`Cannot read entries for ${req.packageSpec}${req.filename}`);
}
);
res
.set({
"Cache-Control": "public,max-age=60", // 1 minute
"Cache-Tag": "index"
})
.send(html);
}
/**
@ -184,21 +158,18 @@ function serveIndex(req, res) {
*/
function serveFile(req, res) {
if (req.query.meta != null) {
serveMetadata(req, res);
} else if (req.stats.isFile()) {
if (req.query.module != null) {
serveJavaScriptModule(req, res);
} else {
serveStaticFile(req, res);
}
} else if (req.stats.isDirectory() && AutoIndex) {
serveIndex(req, res);
} else {
res
.status(403)
.type("text")
.send(`Cannot serve ${req.packageSpec}${req.filename}; it's not a file`);
return serveMetadata(req, res);
}
if (req.entry.type === "directory") {
return serveIndex(req, res);
}
if (req.query.module != null) {
return serveJavaScriptModule(req, res);
}
serveStaticFile(req, res);
}
module.exports = serveFile;

View File

@ -1,32 +1,26 @@
const path = require("path");
const formatBytes = require("pretty-bytes");
const sortBy = require("sort-by");
const getFileContentType = require("../utils/getFileContentType");
const cloneElement = require("./utils/cloneElement");
const e = require("./utils/createElement");
function formatTime(time) {
return new Date(time).toISOString();
function stripLeadingSegment(name) {
return name.replace(/^[^\/]+\//, "");
}
function DirectoryListing({ dir, entries }) {
const rows = entries.map(({ file, stats }, index) => {
const isDir = stats.isDirectory();
const href = file + (isDir ? "/" : "");
function getValues(object) {
return Object.keys(object).map(key => object[key]);
}
return e(
"tr",
{ key: file, className: index % 2 ? "odd" : "even" },
e("td", null, e("a", { title: file, href }, file)),
e("td", null, isDir ? "-" : getFileContentType(file)),
e("td", null, isDir ? "-" : formatBytes(stats.size)),
e("td", null, isDir ? "-" : formatTime(stats.mtime))
);
});
function DirectoryListing({ filename, entry, entries }) {
const rows = [];
if (dir !== "/") {
rows.unshift(
if (filename !== "/") {
rows.push(
e(
"tr",
{ key: "..", className: "odd" },
{ key: ".." },
e("td", null, e("a", { title: "Parent directory", href: "../" }, "..")),
e("td", null, "-"),
e("td", null, "-"),
@ -35,6 +29,48 @@ function DirectoryListing({ dir, entries }) {
);
}
const matchingEntries = getValues(entries).filter(
({ name }) =>
entry.name !== name && path.dirname(name) === (entry.name || ".")
);
matchingEntries
.filter(({ type }) => type === "directory")
.sort(sortBy("name"))
.forEach(({ name }) => {
const relName = stripLeadingSegment(name);
const href = relName + "/";
rows.push(
e(
"tr",
{ key: name },
e("td", null, e("a", { title: relName, href }, href)),
e("td", null, "-"),
e("td", null, "-"),
e("td", null, "-")
)
);
});
matchingEntries
.filter(({ type }) => type === "file")
.sort(sortBy("name"))
.forEach(({ name, size, contentType, lastModified }) => {
const relName = stripLeadingSegment(name);
rows.push(
e(
"tr",
{ key: name },
e("td", null, e("a", { title: relName, href: relName }, relName)),
e("td", null, contentType),
e("td", null, formatBytes(size)),
e("td", null, lastModified)
)
);
});
return e(
"table",
null,
@ -50,7 +86,15 @@ function DirectoryListing({ dir, entries }) {
e("th", null, "Last Modified")
)
),
e("tbody", null, rows)
e(
"tbody",
null,
rows.map((row, index) =>
cloneElement(row, {
className: index % 2 ? "odd" : "even"
})
)
)
);
}

View File

@ -18,7 +18,7 @@ function byVersion(a, b) {
return semver.lt(a, b) ? -1 : semver.gt(a, b) ? 1 : 0;
}
function IndexPage({ packageInfo, version, dir, entries }) {
function IndexPage({ packageInfo, version, filename, entry, entries }) {
const versions = Object.keys(packageInfo.versions).sort(byVersion);
const options = versions.map(v =>
e("option", { key: v, value: v }, `${packageInfo.name}@${v}`)
@ -31,7 +31,7 @@ function IndexPage({ packageInfo, version, dir, entries }) {
"head",
null,
e("meta", { charSet: "utf-8" }),
e("title", null, `Index of ${dir}`),
e("title", null, `Index of ${filename}`),
s(IndexPageStyle)
),
e(
@ -45,10 +45,10 @@ function IndexPage({ packageInfo, version, dir, entries }) {
{ className: "version-wrapper" },
e("select", { id: "version", defaultValue: version }, options)
),
e("h1", null, `Index of ${dir}`),
e("h1", null, `Index of ${filename}`),
x(IndexPageScript),
e("hr"),
e(DirectoryListing, { dir, entries }),
e(DirectoryListing, { filename, entry, entries }),
e("hr"),
e("address", null, `${packageInfo.name}@${version}`)
)

View File

@ -0,0 +1,2 @@
const React = require("react");
module.exports = React.cloneElement;

View File

@ -1,10 +1,13 @@
const semver = require("semver");
const addLeadingSlash = require("../utils/addLeadingSlash");
const createPackageURL = require("../utils/createPackageURL");
const getPackageInfo = require("../utils/getPackageInfo");
const getPackage = require("../utils/getPackage");
const incrementCounter = require("../utils/incrementCounter");
function tagRedirect(req, res) {
const version = req.packageInfo["dist-tags"][req.packageVersion];
// Cache tag redirects for 1 minute.
res
.set({
@ -13,12 +16,7 @@ function tagRedirect(req, res) {
})
.redirect(
302,
createPackageURL(
req.packageName,
req.packageInfo["dist-tags"][req.packageVersion],
req.filename,
req.search
)
createPackageURL(req.packageName, version, req.filename, req.search)
);
}
@ -47,9 +45,71 @@ function semverRedirect(req, res) {
}
}
function filenameRedirect(req, res) {
let filename;
if (req.query.module != null) {
// See https://github.com/rollup/rollup/wiki/pkg.module
filename =
req.packageConfig.module ||
req.packageConfig["jsnext:main"] ||
"/index.js";
} else if (
req.query.main &&
req.packageConfig[req.query.main] &&
typeof req.packageConfig[req.query.main] === "string"
) {
// Deprecated, see #63
filename = req.packageConfig[req.query.main];
// Count which packages are using this so we can warn them when we
// remove this functionality.
incrementCounter(
"package-json-custom-main",
req.packageSpec + "?main=" + req.query.main,
1
);
} else if (
req.packageConfig.unpkg &&
typeof req.packageConfig.unpkg === "string"
) {
filename = req.packageConfig.unpkg;
} else if (
req.packageConfig.browser &&
typeof req.packageConfig.browser === "string"
) {
// Deprecated, see #63
filename = req.packageConfig.browser;
// Count which packages are using this so we can warn them when we
// remove this functionality.
incrementCounter("package-json-browser-fallback", req.packageSpec, 1);
} else {
filename = req.packageConfig.main || "/index.js";
}
// Redirect to the exact filename so relative imports
// and URLs resolve correctly.
// TODO: increase the max-age?
res
.set({
"Cache-Control": "public,max-age=60",
"Cache-Tag": "redirect,filename-redirect"
})
.redirect(
302,
createPackageURL(
req.packageName,
req.packageVersion,
addLeadingSlash(filename),
createSearch(req.query)
)
);
}
/**
* Fetch the package metadata and tarball from npm. Redirect to the exact
* version if the request targets a tag or uses a semver version.
* version if the request targets a tag or uses a semver version, or to the
* exact filename if the request omits the filename.
*/
function fetchPackage(req, res, next) {
getPackageInfo(req.packageName).then(
@ -62,30 +122,21 @@ function fetchPackage(req, res, next) {
}
req.packageInfo = packageInfo;
req.packageConfig = req.packageInfo.versions[req.packageVersion];
if (req.packageVersion in req.packageInfo.versions) {
// A valid request for a package we haven't downloaded yet.
req.packageConfig = req.packageInfo.versions[req.packageVersion];
getPackage(req.packageConfig).then(
outputDir => {
req.packageDir = outputDir;
next();
},
error => {
console.error(error);
res
.status(500)
.type("text")
.send(`Cannot fetch package ${req.packageSpec}`);
}
);
} else if (req.packageVersion in req.packageInfo["dist-tags"]) {
tagRedirect(req, res);
} else {
semverRedirect(req, res);
if (!req.packageConfig) {
if (req.packageVersion in req.packageInfo["dist-tags"]) {
return tagRedirect(req, res);
} else {
return semverRedirect(req, res);
}
}
if (!req.filename) {
return filenameRedirect(req, res);
}
next();
},
error => {
console.error(error);

View File

@ -1,166 +1,178 @@
const fs = require("fs");
const path = require("path");
const fetch = require("isomorphic-fetch");
const gunzip = require("gunzip-maybe");
const tar = require("tar-stream");
const addLeadingSlash = require("../utils/addLeadingSlash");
const createPackageURL = require("../utils/createPackageURL");
const createSearch = require("../utils/createSearch");
const incrementCounter = require("../utils/incrementCounter");
const fetchArchive = require("../utils/fetchArchive");
const getIntegrity = require("../utils/getIntegrity");
const getContentType = require("../utils/getContentType");
/**
* File extensions to look for when automatically resolving.
*/
const resolveExtensions = ["", ".js", ".json"];
function indexRedirect(req, res, entry) {
// Redirect to the index file so relative imports
// resolve correctly.
// TODO: increase the max-age?
res
.set({
"Cache-Control": "public,max-age=60",
"Cache-Tag": "redirect,index-redirect"
})
.redirect(
302,
createPackageURL(
req.packageName,
req.packageVersion,
addLeadingSlash(entry.name),
createSearch(req.query)
)
);
}
/**
* Resolves a path like "lib/file" into "lib/file.js" or "lib/file.json"
* depending on which one is available, similar to require('lib/file').
*/
function resolveFile(base, useIndex, callback) {
resolveExtensions.reduceRight((next, ext) => {
const file = base + ext;
function stripLeadingSegment(name) {
return name.replace(/^[^\/]+\/?/, "");
}
return () => {
fs.stat(file, (error, stats) => {
if (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
next();
} else {
callback(error);
}
} else if (useIndex && stats.isDirectory()) {
resolveFile(
path.join(file, "index"),
false,
(error, indexFile, indexStats) => {
if (error) {
callback(error);
} else if (indexFile) {
callback(null, indexFile, indexStats);
} else {
next();
}
}
);
} else {
callback(null, file, stats);
function searchEntries(tarballStream, entryName, wantsHTML) {
return new Promise((resolve, reject) => {
const entries = {};
let foundEntry = null;
if (entryName === "") {
foundEntry = entries[""] = { name: "", type: "directory" };
}
tarballStream
.on("error", reject)
.on("finish", () => resolve({ entries, foundEntry }))
.on("entry", (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
name: stripLeadingSegment(header.name),
type: header.type
};
// We are only interested in files that match the entryName.
if (entry.type !== "file" || entry.name.indexOf(entryName) !== 0) {
stream.resume();
stream.on("end", next);
return;
}
entries[entry.name] = entry;
// Dynamically create "directory" entries for all directories
// that are in this file's path. Some tarballs omit these entries
// for some reason, so this is the brute force method.
let dirname = path.dirname(entry.name);
while (dirname !== ".") {
const directoryEntry = { name: dirname, type: "directory" };
if (!entries[dirname]) {
entries[dirname] = directoryEntry;
if (directoryEntry.name === entryName) {
foundEntry = directoryEntry;
}
}
dirname = path.dirname(dirname);
}
// Set the foundEntry variable if this entry name
// matches exactly or if it's an index.html file
// and the client wants HTML.
if (
entry.name === entryName ||
(wantsHTML && entry.name === entryName + "/index.html")
) {
foundEntry = entry;
}
const chunks = [];
stream.on("data", chunk => chunks.push(chunk));
stream.on("end", () => {
const content = Buffer.concat(chunks);
// Set some extra properties for files that we will
// need to serve them and for ?meta listings.
entry.contentType = getContentType(entry.name);
entry.integrity = getIntegrity(content);
entry.lastModified = header.mtime.toUTCString();
entry.size = content.length;
// Set the content only for the foundEntry and
// discard the buffer for all others.
if (entry === foundEntry) {
entry.content = content;
}
next();
});
});
};
}, callback)();
});
}
function getBasename(file) {
return path.basename(file, path.extname(file));
}
const leadingSlash = /^\//;
const trailingSlash = /\/$/;
/**
* Find the file targeted by the request and get its stats. Redirect
* inexact paths in ?module mode so relative imports resolve correctly.
* Fetch and search the archive to try and find the requested file.
* Redirect to the "index" file if a directory was requested.
*/
function findFile(req, res, next) {
let filename = req.filename;
let useIndex = true;
fetchArchive(req.packageConfig).then(tarballStream => {
const entryName = req.filename
.replace(trailingSlash, "")
.replace(leadingSlash, "");
const wantsHTML = trailingSlash.test(req.filename);
if (req.query.module != null) {
// They want an ES module.
if (!filename) {
// See https://github.com/rollup/rollup/wiki/pkg.module
filename =
req.packageConfig.module || req.packageConfig["jsnext:main"] || "/";
}
} else if (filename) {
// They are requesting an explicit filename. Only try to find an
// index.js if they are NOT requesting an index page.
useIndex = filename.charAt(filename.length - 1) !== "/";
} else if (
req.query.main &&
typeof req.packageConfig[req.query.main] === "string"
) {
// They specified a custom ?main field.
// Deprecated, see https://github.com/unpkg/unpkg/issues/63
filename = req.packageConfig[req.query.main];
searchEntries(tarballStream, entryName, wantsHTML).then(
({ entries, foundEntry }) => {
if (!foundEntry) {
return res
.status(404)
.type("text")
.send(`Cannot find "${req.filename}" in ${req.packageSpec}`);
}
// Count which packages are using this so we can warn them when we
// remove this functionality.
incrementCounter(
"package-json-custom-main",
req.packageSpec + "?main=" + req.query.main,
1
// If the foundEntry is a directory and there is no trailing slash
// on the request path, we need to redirect to some "index" file
// inside that directory. This is so our URLs work in a similar way
// to require("lib") in node where it searches for `lib/index.js`
// and `lib/index.json` when `lib` is a directory.
if (foundEntry.type === "directory" && !wantsHTML) {
const indexEntry =
entries[path.join(entryName, "index.js")] ||
entries[path.join(entryName, "index.json")];
if (indexEntry && indexEntry.type === "file") {
return indexRedirect(req, res, indexEntry);
} else {
return res
.status(404)
.type("text")
.send(
`Cannot find an index in "${req.filename}" in ${
req.packageSpec
}`
);
}
}
req.entries = entries;
req.entry = foundEntry;
next();
}
);
} else if (typeof req.packageConfig.unpkg === "string") {
// The "unpkg" field allows packages to explicitly declare the
// file to serve at the bare URL.
filename = req.packageConfig.unpkg;
} else if (typeof req.packageConfig.browser === "string") {
// Fall back to the "browser" field if declared (only support strings).
// Deprecated, see https://github.com/unpkg/unpkg/issues/63
filename = req.packageConfig.browser;
// Count which packages + versions are actually using this fallback
// so we can warn them when we deprecate this functionality.
incrementCounter("package-json-browser-fallback", req.packageSpec, 1);
} else {
// Fall back to "main" or / (same as npm).
filename = req.packageConfig.main || "/";
}
resolveFile(
path.join(req.packageDir, filename),
useIndex,
(error, file, stats) => {
if (error) console.error(error);
if (file == null) {
return res
.status(404)
.type("text")
.send(
`Cannot find module "${filename}" in package ${req.packageSpec}`
);
}
filename = file.replace(req.packageDir, "");
if (req.query.main != null) {
// Permanently redirect ?main requests to their exact files.
// Deprecated, see https://github.com/unpkg/unpkg/issues/63
delete req.query.main;
return res.redirect(
301,
createPackageURL(
req.packageName,
req.packageVersion,
filename,
createSearch(req.query)
)
);
}
if (getBasename(req.filename) !== getBasename(filename)) {
// Redirect to the exact file so relative imports resolve correctly.
// Cache module redirects for 1 minute.
return res
.set({
"Cache-Control": "public, max-age=60",
"Cache-Tag": "redirect,module-redirect"
})
.redirect(
302,
createPackageURL(
req.packageName,
req.packageVersion,
filename,
createSearch(req.query)
)
);
}
req.filename = filename;
req.stats = stats;
next();
}
);
});
}
module.exports = findFile;

View File

@ -0,0 +1,39 @@
const getContentType = require("../getContentType");
it("gets a content type of text/plain for LICENSE|README|CHANGES|AUTHORS|Makefile", () => {
expect(getContentType("AUTHORS")).toBe("text/plain");
expect(getContentType("CHANGES")).toBe("text/plain");
expect(getContentType("LICENSE")).toBe("text/plain");
expect(getContentType("Makefile")).toBe("text/plain");
expect(getContentType("PATENTS")).toBe("text/plain");
expect(getContentType("README")).toBe("text/plain");
});
it("gets a content type of text/plain for .*rc files", () => {
expect(getContentType(".eslintrc")).toBe("text/plain");
expect(getContentType(".babelrc")).toBe("text/plain");
expect(getContentType(".anythingrc")).toBe("text/plain");
});
it("gets a content type of text/plain for .git* files", () => {
expect(getContentType(".gitignore")).toBe("text/plain");
expect(getContentType(".gitanything")).toBe("text/plain");
});
it("gets a content type of text/plain for .*ignore files", () => {
expect(getContentType(".eslintignore")).toBe("text/plain");
expect(getContentType(".anythingignore")).toBe("text/plain");
});
it("gets a content type of text/plain for .ts files", () => {
expect(getContentType("app.ts")).toBe("text/plain");
expect(getContentType("app.d.ts")).toBe("text/plain");
});
it("gets a content type of text/plain for .flow files", () => {
expect(getContentType("app.js.flow")).toBe("text/plain");
});
it("gets a content type of text/plain for .lock files", () => {
expect(getContentType("yarn.lock")).toBe("text/plain");
});

View File

@ -1,35 +0,0 @@
const getFileContentType = require("../getFileContentType");
it("gets a content type of text/plain for LICENSE|README|CHANGES|AUTHORS|Makefile", () => {
expect(getFileContentType("AUTHORS")).toBe("text/plain");
expect(getFileContentType("CHANGES")).toBe("text/plain");
expect(getFileContentType("LICENSE")).toBe("text/plain");
expect(getFileContentType("Makefile")).toBe("text/plain");
expect(getFileContentType("PATENTS")).toBe("text/plain");
expect(getFileContentType("README")).toBe("text/plain");
});
it("gets a content type of text/plain for .*rc files", () => {
expect(getFileContentType(".eslintrc")).toBe("text/plain");
expect(getFileContentType(".babelrc")).toBe("text/plain");
expect(getFileContentType(".anythingrc")).toBe("text/plain");
});
it("gets a content type of text/plain for .git* files", () => {
expect(getFileContentType(".gitignore")).toBe("text/plain");
expect(getFileContentType(".gitanything")).toBe("text/plain");
});
it("gets a content type of text/plain for .*ignore files", () => {
expect(getFileContentType(".eslintignore")).toBe("text/plain");
expect(getFileContentType(".anythingignore")).toBe("text/plain");
});
it("gets a content type of text/plain for .ts files", () => {
expect(getFileContentType("app.ts")).toBe("text/plain");
expect(getFileContentType("app.d.ts")).toBe("text/plain");
});
it("gets a content type of text/plain for .flow files", () => {
expect(getFileContentType("app.js.flow")).toBe("text/plain");
});

View File

@ -0,0 +1,5 @@
function addLeadingSlash(name) {
return name.charAt(0) === "/" ? name : "/" + name;
}
module.exports = addLeadingSlash;

View File

@ -1,9 +0,0 @@
const path = require("path");
const tmpdir = require("os-tmpdir");
function createTempPath(name, version) {
const hyphenName = name.replace(/\//g, "-");
return path.join(tmpdir(), `unpkg-${hyphenName}-${version}`);
}
module.exports = createTempPath;

View File

@ -0,0 +1,13 @@
const fetch = require("isomorphic-fetch");
const gunzip = require("gunzip-maybe");
const tar = require("tar-stream");
function fetchArchive(packageConfig) {
const tarballURL = packageConfig.dist.tarball;
return fetch(tarballURL).then(res =>
res.body.pipe(gunzip()).pipe(tar.extract())
);
}
module.exports = fetchArchive;

View File

@ -1,43 +0,0 @@
const fetch = require("isomorphic-fetch");
const gunzip = require("gunzip-maybe");
const tar = require("tar-fs");
function stripNamePrefix(header) {
// Most packages have header names that look like "package/index.js"
// so we shorten that to just "index.js" here. A few packages use a
// prefix other than "package/". e.g. the firebase package uses the
// "firebase_npm/" prefix. So we just strip the first dir name.
header.name = header.name.replace(/^[^/]+\//, "");
return header;
}
function ignoreLinks(file, header) {
return (
header.type === "link" ||
header.type === "symlink" ||
(header.type === "directory" && !header.name.includes("/")) // Empty directory, see #99
);
}
function extractResponse(response, outputDir) {
return new Promise((resolve, reject) => {
const extract = tar.extract(outputDir, {
readable: true, // All dirs/files should be readable.
map: stripNamePrefix,
ignore: ignoreLinks
});
response.body
.pipe(gunzip())
.pipe(extract)
.on("finish", resolve)
.on("error", reject);
});
}
function fetchPackage(tarballURL, outputDir) {
console.log(`info: Fetching ${tarballURL} and extracting to ${outputDir}`);
return fetch(tarballURL).then(res => extractResponse(res, outputDir));
}
module.exports = fetchPackage;

View File

@ -1,4 +1,4 @@
require("isomorphic-fetch");
const fetch = require("isomorphic-fetch");
const config = require("../config");

View File

@ -0,0 +1,22 @@
const mime = require("mime");
mime.define({
"text/plain": [
"authors",
"changes",
"license",
"makefile",
"patents",
"readme",
"ts",
"flow"
]
});
const textFiles = /\/?(\.[a-z]*rc|\.git[a-z]*|\.[a-z]*ignore|\.lock)$/i;
function getContentType(file) {
return textFiles.test(file) ? "text/plain" : mime.lookup(file);
}
module.exports = getContentType;

View File

@ -1,26 +0,0 @@
const fs = require("fs");
const path = require("path");
const getFileStats = require("./getFileStats");
function getEntries(dir) {
return new Promise((resolve, reject) => {
fs.readdir(dir, function(error, files) {
if (error) {
reject(error);
} else {
resolve(
Promise.all(
files.map(file => getFileStats(path.join(dir, file)))
).then(statsArray => {
return statsArray.map((stats, index) => {
return { file: files[index], stats };
});
})
);
}
});
});
}
module.exports = getEntries;

View File

@ -1,22 +0,0 @@
const mime = require("mime");
mime.define({
"text/plain": [
"authors",
"changes",
"license",
"makefile",
"patents",
"readme",
"ts",
"flow"
]
});
const TextFiles = /\/?(\.[a-z]*rc|\.git[a-z]*|\.[a-z]*ignore)$/i;
function getFileContentType(file) {
return TextFiles.test(file) ? "text/plain" : mime.lookup(file);
}
module.exports = getFileContentType;

View File

@ -1,15 +0,0 @@
const fs = require("fs");
function getFileStats(file) {
return new Promise((resolve, reject) => {
fs.lstat(file, (error, stats) => {
if (error) {
reject(error);
} else {
resolve(stats);
}
});
});
}
module.exports = getFileStats;

View File

@ -1,12 +0,0 @@
function getFileType(stats) {
if (stats.isFile()) return "file";
if (stats.isDirectory()) return "directory";
if (stats.isBlockDevice()) return "blockDevice";
if (stats.isCharacterDevice()) return "characterDevice";
if (stats.isSymbolicLink()) return "symlink";
if (stats.isSocket()) return "socket";
if (stats.isFIFO()) return "fifo";
return "unknown";
}
module.exports = getFileType;

View File

@ -0,0 +1,7 @@
const SRIToolbox = require("sri-toolbox");
function getIntegrity(data) {
return SRIToolbox.generate({ algorithms: ["sha384"] }, data);
}
module.exports = getIntegrity;

View File

@ -1,83 +0,0 @@
const fs = require("fs");
const path = require("path");
const SRIToolbox = require("sri-toolbox");
const getFileContentType = require("./getFileContentType");
const getFileStats = require("./getFileStats");
const getFileType = require("./getFileType");
function getEntries(dir, file, maximumDepth) {
return new Promise((resolve, reject) => {
fs.readdir(path.join(dir, file), (error, files) => {
if (error) {
reject(error);
} else {
resolve(
Promise.all(
files.map(f => getFileStats(path.join(dir, file, f)))
).then(statsArray => {
return Promise.all(
statsArray.map((stats, index) =>
getMetadataRecursive(
dir,
path.join(file, files[index]),
stats,
maximumDepth - 1
)
)
);
})
);
}
});
});
}
function formatTime(time) {
return new Date(time).toISOString();
}
function getIntegrity(file) {
return new Promise((resolve, reject) => {
fs.readFile(file, (error, data) => {
if (error) {
reject(error);
} else {
resolve(SRIToolbox.generate({ algorithms: ["sha384"] }, data));
}
});
});
}
function getMetadataRecursive(dir, file, stats, maximumDepth) {
const metadata = {
lastModified: formatTime(stats.mtime),
contentType: getFileContentType(file),
path: file,
size: stats.size,
type: getFileType(stats)
};
if (stats.isFile()) {
return getIntegrity(path.join(dir, file)).then(integrity => {
metadata.integrity = integrity;
return metadata;
});
}
if (!stats.isDirectory() || maximumDepth === 0)
return Promise.resolve(metadata);
return getEntries(dir, file, maximumDepth).then(files => {
metadata.files = files;
return metadata;
});
}
function getMetadata(baseDir, path, stats, maximumDepth, callback) {
getMetadataRecursive(baseDir, path, stats, maximumDepth).then(metadata => {
callback(null, metadata);
}, callback);
}
module.exports = getMetadata;

View File

@ -1,78 +0,0 @@
const fs = require("fs");
const mkdirp = require("mkdirp");
const lockfile = require("proper-lockfile");
const createMutex = require("./createMutex");
const createTempPath = require("./createTempPath");
const fetchPackage = require("./fetchPackage");
const fetchMutex = createMutex((packageConfig, callback) => {
const tarballURL = packageConfig.dist.tarball;
const outputDir = createTempPath(packageConfig.name, packageConfig.version);
fs.access(outputDir, error => {
if (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
// ENOENT or ENOTDIR are to be expected when we haven't yet
// fetched a package for the first time. Carry on!
mkdirp.sync(outputDir);
const release = lockfile.lockSync(outputDir);
fetchPackage(tarballURL, outputDir).then(
() => {
release();
callback(null, outputDir);
},
error => {
release();
callback(error);
}
);
} else {
callback(error);
}
} else {
lockfile.check(outputDir).then(locked => {
if (locked) {
// Another process on this same machine has locked the
// directory. We need to wait for it to be unlocked
// before we callback.
const timer = setInterval(() => {
lockfile.check(outputDir).then(
locked => {
if (!locked) {
clearInterval(timer);
callback(null, outputDir);
}
},
error => {
clearInterval(timer);
callback(error);
}
);
}, 10);
timer.unref();
} else {
// Best case: we already have this package cached on disk
// and it's not locked!
callback(null, outputDir);
}
}, callback);
}
});
}, packageConfig => packageConfig.dist.tarball);
function getPackage(packageConfig) {
return new Promise((resolve, reject) => {
fetchMutex(packageConfig, (error, value) => {
if (error) {
reject(error);
} else {
resolve(value);
}
});
});
}
module.exports = getPackage;

View File

@ -1312,10 +1312,25 @@ bser@^2.0.0:
dependencies:
node-int64 "^0.4.0"
buffer-alloc-unsafe@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
buffer-alloc@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
dependencies:
buffer-alloc-unsafe "^1.1.0"
buffer-fill "^1.0.0"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
buffer-fill@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
buffer-from@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.0.0.tgz#4cb8832d23612589b0406e9e2956c17f06fdf531"
@ -2877,6 +2892,10 @@ fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
fs-minipass@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
@ -4956,6 +4975,10 @@ object-keys@^1.0.8:
version "1.0.11"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d"
object-path@0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.6.0.tgz#b69a7d110937934f336ca561fd9be1ad7b7e0cb7"
object-visit@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
@ -5060,7 +5083,7 @@ os-locale@^2.0.0:
lcid "^1.0.0"
mem "^1.1.0"
os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@^1.0.2, os-tmpdir@~1.0.2:
os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
@ -5580,13 +5603,6 @@ prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0:
loose-envify "^1.3.1"
object-assign "^4.1.1"
proper-lockfile@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-3.0.2.tgz#d30b3b83ecb157e08fe0d411f2393bc384b77ad1"
dependencies:
graceful-fs "^4.1.11"
retry "^0.10.1"
proxy-addr@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
@ -5612,13 +5628,6 @@ public-encrypt@^4.0.0:
parse-asn1 "^5.0.0"
randombytes "^2.0.1"
pump@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
pump@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
@ -5832,6 +5841,18 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable
string_decoder "~1.0.3"
util-deprecate "~1.0.1"
readable-stream@^2.3.0:
version "2.3.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.3"
isarray "~1.0.0"
process-nextick-args "~2.0.0"
safe-buffer "~5.1.1"
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readdirp@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78"
@ -6105,10 +6126,6 @@ ret@~0.1.10:
version "0.1.15"
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
retry@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4"
right-align@^0.1.1:
version "0.1.3"
resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
@ -6374,6 +6391,12 @@ sockjs@0.3.19:
faye-websocket "^0.10.0"
uuid "^3.0.1"
sort-by@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/sort-by/-/sort-by-1.2.0.tgz#ed92bbff9fd2284b41f6503e38496607b225fe6f"
dependencies:
object-path "0.6.0"
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@ -6580,6 +6603,12 @@ string_decoder@^1.0.0, string_decoder@~1.0.3:
dependencies:
safe-buffer "~5.1.0"
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
dependencies:
safe-buffer "~5.1.0"
stringstream@~0.0.4:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
@ -6707,15 +6736,6 @@ tapable@^0.2.7:
version "0.2.8"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22"
tar-fs@^1.16.2:
version "1.16.2"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.2.tgz#17e5239747e399f7e77344f5f53365f04af53577"
dependencies:
chownr "^1.0.1"
mkdirp "^0.5.1"
pump "^1.0.0"
tar-stream "^1.1.2"
tar-pack@^3.4.0:
version "3.4.1"
resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.1.tgz#e1dbc03a9b9d3ba07e896ad027317eb679a10a1f"
@ -6729,13 +6749,16 @@ tar-pack@^3.4.0:
tar "^2.2.1"
uid-number "^0.0.6"
tar-stream@^1.1.2:
version "1.5.5"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.5.5.tgz#5cad84779f45c83b1f2508d96b09d88c7218af55"
tar-stream@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.1.tgz#f84ef1696269d6223ca48f6e1eeede3f7e81f395"
dependencies:
bl "^1.0.0"
buffer-alloc "^1.1.0"
end-of-stream "^1.0.0"
readable-stream "^2.0.0"
fs-constants "^1.0.0"
readable-stream "^2.3.0"
to-buffer "^1.1.0"
xtend "^4.0.0"
tar@^2.2.1:
@ -6825,6 +6848,10 @@ to-arraybuffer@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
to-buffer@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
to-fast-properties@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"