snowpack
Version:
The ESM-powered frontend build tool. Fast, lightweight, unbundled.
444 lines (443 loc) • 18.2 kB
JavaScript
import etag from 'etag';
import execa from 'execa';
import findUp from 'find-up';
import fs from 'fs';
import { isBinaryFile } from 'isbinaryfile';
import mkdirp from 'mkdirp';
import open from 'open';
import path from 'path';
import rimraf from 'rimraf';
import url from 'url';
import getDefaultBrowserId from 'default-browser-id';
import { SkypackSDK } from 'skypack';
import { REMOTE_PACKAGE_ORIGIN } from './config';
import { GLOBAL_CACHE_DIR } from './sources/util';
// (!) Beware circular dependencies! No relative imports!
// Because this file is imported from so many different parts of Snowpack,
// importing other relative files inside of it is likely to introduce broken
// circular dependencies (sometimes only visible in the final bundled build.)
export const IS_DOTFILE_REGEX = /\/\.[^\/]+$/; // note: always assume forward-slashes, even on Windows
export const LOCKFILE_NAME = 'snowpack.deps.json';
// We need to use eval here to prevent Rollup from detecting this use of `require()`
export const NATIVE_REQUIRE = eval('require');
// We need to use an external file here to prevent Typescript/Rollup from modifying `require` and `import`
// NOTE: revisit this when `node@10` reaches EOL. Can we move everything to ESM and just use `import`?
export const REQUIRE_OR_IMPORT = require('../../assets/require-or-import.js');
export function createRemotePackageSDK(config) {
// This should only be called when config.packageOptions.source is 'remote'.
if (config.packageOptions.source !== 'remote') {
throw new Error('expected "remote" packageOptions.source');
}
// For consistency with previous behavior, we default to REMOTE_PACKAGE_ORIGIN
// if no origin is provided. We could simply leave it undefined and allow
// SkypackSDK to use its own default.
return new SkypackSDK({
origin: config.packageOptions.origin || REMOTE_PACKAGE_ORIGIN,
});
}
// A note on cache naming/versioning: We currently version our global caches
// with the version of the last breaking change. This allows us to re-use the
// same cache across versions until something in the data structure changes.
// At that point, bump the version in the cache name to create a new unique
// cache name.
export const BUILD_CACHE = path.join(GLOBAL_CACHE_DIR, 'build-cache-2.7');
const LOCKFILE_HASH_FILE = '.hash';
// NOTE(fks): Must match empty script elements to work properly.
export const HTML_JS_REGEX = /(<script[^>]*?type="module".*?>)(.*?)<\/script>/gims;
export const HTML_STYLE_REGEX = /(<style.*?>)(.*?)<\/style>/gims;
export const CSS_REGEX = /@import\s*['"](.*?)['"];/gs;
export const SVELTE_VUE_REGEX = /(<script[^>]*>)(.*?)<\/script>/gims;
export const ASTRO_REGEX = /---(.*?)---/gims;
export function getCacheKey(fileLoc, { isSSR, mode }) {
return `${fileLoc}?mode=${mode}&isSSR=${isSSR ? '1' : '0'}`;
}
/**
* Like rimraf, but will fail if "dir" is outside of your configured build output directory.
*/
export function deleteFromBuildSafe(dir, config) {
const { out } = config.buildOptions;
if (!path.isAbsolute(dir)) {
throw new Error(`rimrafSafe(): dir ${dir} must be a absolute path`);
}
if (!path.isAbsolute(out)) {
throw new Error(`rimrafSafe(): buildOptions.out ${out} must be a absolute path`);
}
if (!dir.startsWith(out)) {
throw new Error(`rimrafSafe(): ${dir} outside of buildOptions.out ${out}`);
}
return rimraf.sync(dir);
}
/** Read file from disk; return a string if it’s a code file */
export async function readFile(filepath) {
let data = await fs.promises.readFile(filepath);
if (!data) {
console.error(`Unexpected Node.js error: readFile(${filepath}) returned undefined.\n\n` +
`Somehow in Github CI / Jest its possible for fs.promises.readFile to return undefined.\n` +
`This should be impossible, and has not yet been reproduced in the real world, but we do see it in our own CI.\n` +
`If you are seeing this error, please report!`);
data = fs.readFileSync(filepath);
}
const isBinary = await isBinaryFile(data);
return isBinary ? data : data.toString('utf8');
}
export async function readLockfile(cwd) {
try {
var lockfileContents = fs.readFileSync(path.join(cwd, LOCKFILE_NAME), {
encoding: 'utf8',
});
}
catch (err) {
// no lockfile found, ignore and continue
return null;
}
// If this fails, we actually do want to alert the user by throwing
return JSON.parse(lockfileContents);
}
export function createInstallTarget(specifier, all = true) {
return {
specifier,
all,
default: false,
namespace: false,
named: [],
};
}
function sortObject(originalObject) {
const newObject = {};
for (const key of Object.keys(originalObject).sort()) {
newObject[key] = originalObject[key];
}
return newObject;
}
export function convertLockfileToSkypackImportMap(origin, lockfile) {
const result = { imports: {} };
for (const [key, val] of Object.entries(lockfile.lock)) {
result.imports[key.replace(/\#.*/, '')] = origin + '/' + val;
result.imports[key.replace(/\#.*/, '') + '/'] = origin + '/' + val + '/';
}
return result;
}
export function convertSkypackImportMapToLockfile(dependencies, importMap) {
const result = { dependencies, lock: {} };
for (const [key, val] of Object.entries(dependencies)) {
if (importMap.imports[key]) {
const valPath = url.parse(importMap.imports[key]).pathname;
result.lock[key + '#' + val] = valPath === null || valPath === void 0 ? void 0 : valPath.substr(1);
}
}
return result;
}
export async function writeLockfile(loc, importMap) {
importMap.dependencies = sortObject(importMap.dependencies);
importMap.lock = sortObject(importMap.lock);
fs.writeFileSync(loc, JSON.stringify(importMap, undefined, 2), { encoding: 'utf8' });
}
export function isTruthy(item) {
return Boolean(item);
}
/**
* Returns true if fsevents exists. When Snowpack is bundled, automatic fsevents
* detection fails for many libraries. This function helps add back support.
*/
export function isFsEventsEnabled() {
try {
NATIVE_REQUIRE('fsevents');
return true;
}
catch (e) {
return false;
}
}
/** Get the package name + an entrypoint within that package (if given). */
export function parsePackageImportSpecifier(imp) {
const impParts = imp.split('/');
if (imp.startsWith('@')) {
const [scope, name, ...rest] = impParts;
return [`${scope}/${name}`, rest.join('/') || null];
}
const [name, ...rest] = impParts;
return [name, rest.join('/') || null];
}
/**
* Given a package name, look for that package's package.json manifest.
* Return both the manifest location (if believed to exist) and the
* manifest itself (if found).
*
* NOTE: You used to be able to require() a package.json file directly,
* but now with export map support in Node v13 that's no longer possible.
*/
export function resolveDependencyManifest(dep, cwd) {
// Attempt #1: Resolve the dependency manifest normally. This works for most
// packages, but fails when the package defines an export map that doesn't
// include a package.json. If we detect that to be the reason for failure,
// move on to our custom implementation.
try {
const depManifest = fs.realpathSync.native(require.resolve(`${dep}/package.json`, { paths: [cwd] }));
return [depManifest, NATIVE_REQUIRE(depManifest)];
}
catch (err) {
// if its an export map issue, move on to our manual resolver.
if (err.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
return [null, null];
}
}
// Attempt #2: Resolve the dependency manifest manually. This involves resolving
// the dep itself to find the entrypoint file, and then haphazardly replacing the
// file path within the package with a "./package.json" instead. It's not as
// thorough as Attempt #1, but it should work well until export maps become more
// established & move out of experimental mode.
let result = [null, null];
try {
const fullPath = fs.realpathSync.native(require.resolve(dep, { paths: [cwd] }));
// Strip everything after the package name to get the package root path
// NOTE: This find-replace is very gross, replace with something like upath.
const searchPath = `${path.sep}node_modules${path.sep}${dep.replace('/', path.sep)}`;
const indexOfSearch = fullPath.lastIndexOf(searchPath);
if (indexOfSearch >= 0) {
const manifestPath = fullPath.substring(0, indexOfSearch + searchPath.length + 1) + 'package.json';
result[0] = manifestPath;
const manifestStr = fs.readFileSync(manifestPath, { encoding: 'utf8' });
result[1] = JSON.parse(manifestStr);
}
}
catch (err) {
// ignore
}
finally {
return result;
}
}
/**
* If Rollup erred parsing a particular file, show suggestions based on its
* file extension (note: lowercase is fine).
*/
export const MISSING_PLUGIN_SUGGESTIONS = {
'.svelte': 'Try installing rollup-plugin-svelte and adding it to Snowpack (https://www.snowpack.dev/tutorials/svelte)',
'.vue': 'Try installing rollup-plugin-vue and adding it to Snowpack (https://www.snowpack.dev/guides/vue)',
};
const appNames = {
win32: {
brave: 'brave',
},
darwin: {
brave: 'Brave Browser',
},
linux: {
brave: 'brave',
},
};
async function openInExistingChromeBrowser(url) {
// see if Chrome process is open; fail if not
await execa.command('ps cax | grep "Google Chrome"', {
shell: true,
});
// use open Chrome tab if exists; create new Chrome tab if not
const openChrome = execa('osascript ../../assets/openChrome.appleScript "' + encodeURI(url) + '"', {
cwd: __dirname,
stdio: 'ignore',
shell: true,
});
// if Chrome doesn’t respond within 3s, fall back to opening new tab in default browser
let isChromeStalled = setTimeout(() => {
openChrome.cancel();
}, 3000);
try {
await openChrome;
}
catch (err) {
if (err.isCanceled) {
console.warn(`Chrome not responding to Snowpack after 3s. Opening in new tab.`);
}
else {
console.error(err.toString() || err);
}
throw err;
}
finally {
clearTimeout(isChromeStalled);
}
}
export async function openInBrowser(protocol, hostname, port, browser, openUrl) {
const url = new URL(openUrl || '', `${protocol}//${hostname}:${port}`).toString();
if (/chrome/i.test(browser)) {
browser = open.apps.chrome;
}
if (/brave/i.test(browser)) {
browser = appNames[process.platform]['brave'];
}
const isMacChrome = process.platform === 'darwin' &&
(/chrome/i.test(browser) ||
(/default/i.test(browser) && /chrome/i.test(await getDefaultBrowserId())));
if (!isMacChrome) {
await (browser === 'default' ? open(url) : open(url, { app: { name: browser } }));
return;
}
try {
// If we're on macOS, and we haven't requested a specific browser,
// we can try opening Chrome with AppleScript. This lets us reuse an
// existing tab when possible instead of creating a new one.
await openInExistingChromeBrowser(url);
}
catch (err) {
// if no open Chrome process, just go ahead and open default browser.
await open(url);
}
}
export async function checkLockfileHash(dir) {
const lockfileLoc = await findUp(['package-lock.json', 'yarn.lock']);
if (!lockfileLoc) {
return true;
}
const hashLoc = path.join(dir, LOCKFILE_HASH_FILE);
const newLockHash = etag(await fs.promises.readFile(lockfileLoc, 'utf8'));
const oldLockHash = await fs.promises.readFile(hashLoc, 'utf8').catch(() => '');
return newLockHash === oldLockHash;
}
export async function updateLockfileHash(dir) {
const lockfileLoc = await findUp(['package-lock.json', 'yarn.lock']);
if (!lockfileLoc) {
return;
}
const hashLoc = path.join(dir, LOCKFILE_HASH_FILE);
const newLockHash = etag(await fs.promises.readFile(lockfileLoc));
await mkdirp(path.dirname(hashLoc));
await fs.promises.writeFile(hashLoc, newLockHash);
}
function getAliasType(val) {
if (isRemoteUrl(val)) {
return 'url';
}
return !path.isAbsolute(val) ? 'package' : 'path';
}
/**
* For the given import specifier, return an alias entry if one is matched.
*/
export function findMatchingAliasEntry(config, spec) {
// Only match bare module specifiers. relative and absolute imports should not match
if (isPathImport(spec) || isRemoteUrl(spec)) {
return undefined;
}
for (const [from, to] of Object.entries(config.alias)) {
const isExactMatch = spec === from;
const isDeepMatch = spec.startsWith(addTrailingSlash(from));
if (isExactMatch || isDeepMatch) {
return {
from,
to,
type: getAliasType(to),
};
}
}
}
/**
* Get the most specific file extension match possible.
*/
export function getExtensionMatch(fileName, extensionMap) {
let extensionPartial;
let extensionMatch;
// If a full URL is given, start at the basename. Otherwise, start at zero.
let extensionMatchIndex = Math.max(0, fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
// Grab expanded file extensions, from longest to shortest.
while (!extensionMatch && extensionMatchIndex > -1) {
extensionMatchIndex++;
extensionMatchIndex = fileName.indexOf('.', extensionMatchIndex);
extensionPartial = fileName.substr(extensionMatchIndex).toLowerCase();
extensionMatch = extensionMap[extensionPartial];
}
// Return the first match, if one was found. Otherwise, return undefined.
return extensionMatch ? [extensionPartial, extensionMatch] : undefined;
}
export function isPathImport(spec) {
return spec[0] === '.' || spec[0] === '/';
}
export function isRemoteUrl(val) {
var _a;
return val.startsWith('//') || !!((_a = url.parse(val).protocol) === null || _a === void 0 ? void 0 : _a.startsWith('http'));
}
export function isImportOfPackage(importUrl, packageName) {
return packageName === importUrl || importUrl.startsWith(packageName + '/');
}
/**
* Sanitizes npm packages that end in .js (e.g `tippy.js` -> `tippyjs`).
* This is necessary because Snowpack can’t create both a file and directory
* that end in .js.
*/
export function sanitizePackageName(filepath) {
const dirs = filepath.split('/');
const file = dirs.pop();
return [...dirs.map((path) => path.replace(/\.js$/i, 'js')), file].join('/');
}
// Source Map spec v3: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit#heading=h.lmz475t4mvbx
/** CSS sourceMappingURL */
export function cssSourceMappingURL(code, sourceMappingURL) {
return code + `/*# sourceMappingURL=${sourceMappingURL} */`;
}
/** JS sourceMappingURL */
export function jsSourceMappingURL(code, sourceMappingURL) {
return code.replace(/\n*$/, '') + `\n//# sourceMappingURL=${sourceMappingURL}\n`; // strip ending lines & append source map (with linebreaks for safety)
}
/** URL relative */
export function relativeURL(path1, path2) {
let url = path.relative(path1, path2).replace(/\\/g, '/');
if (!url.startsWith('./') && !url.startsWith('../')) {
url = './' + url;
}
return url;
}
const CLOSING_HEAD_TAG = /<\s*\/\s*head\s*>/gi;
/** Append HTML before closing </head> tag */
export function appendHtmlToHead(doc, htmlToAdd) {
const closingHeadMatch = doc.match(CLOSING_HEAD_TAG);
// if no <head> tag found, throw an error (we can’t load your app properly)
if (!closingHeadMatch) {
throw new Error(`No <head> tag found in HTML (this is needed to optimize your app):\n${doc}`);
}
// if multiple <head> tags found, also freak out
if (closingHeadMatch.length > 1) {
throw new Error(`Multiple <head> tags found in HTML (perhaps commented out?):\n${doc}`);
}
return doc.replace(closingHeadMatch[0], htmlToAdd + closingHeadMatch[0]);
}
export function isJavaScript(pathname) {
const ext = path.extname(pathname).toLowerCase();
return ext === '.js' || ext === '.mjs' || ext === '.cjs';
}
export function getExtension(str) {
return path.extname(str).toLowerCase();
}
export function hasExtension(str, ext) {
return new RegExp(`\\${ext}$`, 'i').test(str);
}
export function replaceExtension(fileName, oldExt, newExt) {
const extToReplace = new RegExp(`\\${oldExt}$`, 'i');
return fileName.replace(extToReplace, newExt);
}
export function addExtension(fileName, newExt) {
return fileName + newExt;
}
export function removeExtension(fileName, oldExt) {
return replaceExtension(fileName, oldExt, '');
}
/** Add / to beginning of string (but don’t double-up) */
export function addLeadingSlash(path) {
return path.replace(/^\/?/, '/');
}
/** Add / to the end of string (but don’t double-up) */
export function addTrailingSlash(path) {
return path.replace(/\/?$/, '/');
}
/** Remove \ and / from beginning of string */
export function removeLeadingSlash(path) {
return path.replace(/^[/\\]+/, '');
}
/** Remove \ and / from end of string */
export function removeTrailingSlash(path) {
return path.replace(/[/\\]+$/, '');
}
/** It's `Array.splice`, but for Strings! */
export function spliceString(source, withSlice, start, end) {
return source.slice(0, start) + (withSlice || '') + source.slice(end);
}
export const HMR_CLIENT_CODE = fs.readFileSync(path.resolve(__dirname, '../../assets/hmr-client.js'), 'utf8');
export const HMR_OVERLAY_CODE = fs.readFileSync(path.resolve(__dirname, '../../assets/hmr-error-overlay.js'), 'utf8');
export const INIT_TEMPLATE_FILE = fs.readFileSync(path.resolve(__dirname, '../../assets/snowpack-init-file.js'), 'utf8');