piral-cli
Version:
The standard CLI for creating and building a Piral instance or a Pilet.
376 lines (332 loc) • 11.6 kB
text/typescript
import { resolve, dirname, isAbsolute, basename } from 'path';
import { log, fail } from './log';
import { satisfies, validate } from './version';
import { computeHash } from './hash';
import { getHash, readJson, findFile, checkExists, checkIsDirectory } from './io';
import { tryResolvePackage } from './npm';
import type { SharedDependency, Importmap, ImportmapVersions, ImportmapMode, PiralPackageData } from '../types';
const shorthandsUrls = ['', '.', '...'];
function addLocalDependencies(
dependencies: Array<SharedDependency>,
realIdentifier: string,
identifier: string,
version: string,
requireVersion: string,
entry: string,
assetName: string,
isAsync: boolean,
) {
const alias = realIdentifier !== identifier ? realIdentifier : undefined;
dependencies.push({
id: `${identifier}@${version}`,
requireId: `${identifier}@${requireVersion}`,
entry,
name: identifier,
ref: `${assetName}.js`,
type: 'local',
alias,
isAsync,
});
}
function getAnyPatch(version: string) {
const [major, minor] = version.split('.');
return `~${major}.${minor}.0`;
}
function getMatchMajor(version: string) {
const [major] = version.split('.');
return `^${major}.0.0`;
}
function makeAssetName(id: string) {
return (id.startsWith('@') ? id.substring(1) : id).replace(/[\/\.]/g, '-').replace(/(\-)+/, '-');
}
function getDependencyDetails(
depName: string,
availableSpecs: Record<string, string>,
): [assetName: string, identifier: string, versionSpec: string, isAsync: boolean] {
const name = depName.replace(/\?+$/, '');
const isAsync = depName.endsWith('?');
const sep = name.indexOf('@', 1);
if (sep > 0) {
const id = name.substring(0, sep);
const version = name.substring(sep + 1);
const assetName = makeAssetName(id);
return [assetName, id, version, isAsync];
} else {
const version = availableSpecs[name] || '';
const assetName = makeAssetName(name);
return [assetName, name, version, isAsync];
}
}
async function getLocalDependencyVersion(
packageJson: string,
depName: string,
versionSpec: string,
versionBehavior: ImportmapVersions,
): Promise<[realIdentifier: string, offeredVersion: string, requiredVersion: string]> {
const packageDir = dirname(packageJson);
const packageFile = basename(packageJson);
const details = await readJson(packageDir, packageFile);
if (versionSpec) {
if (!validate(versionSpec)) {
fail('importMapVersionSpecInvalid_0026', depName);
}
if (!satisfies(details.version, versionSpec)) {
fail('importMapVersionSpecNotSatisfied_0025', depName, details.version, versionSpec);
}
return [details.name, details.version, versionSpec];
}
switch (versionBehavior) {
case 'all':
return [details.name, details.version, '*'];
case 'match-major':
return [details.name, details.version, getMatchMajor(details.version)];
case 'any-patch':
return [details.name, details.version, getAnyPatch(details.version)];
case 'exact':
default:
return [details.name, details.version, details.version];
}
}
async function getInheritedDependencies(
inheritedImport: string,
dir: string,
excludedDependencies: Array<string>,
inheritanceBehavior: ImportmapMode,
): Promise<Array<SharedDependency>> {
const packageJson = tryResolvePackage(`${inheritedImport}/package.json`, dir);
if (packageJson) {
const packageDir = dirname(packageJson);
const packageDetails = await readJson(packageDir, 'package.json');
return await consumeImportmap(packageDir, packageDetails, true, 'exact', inheritanceBehavior, excludedDependencies);
} else {
const directImportmap = tryResolvePackage(inheritedImport, dir);
if (directImportmap) {
const baseDir = dirname(directImportmap);
const content = await readJson(baseDir, basename(directImportmap));
return await resolveImportmap(baseDir, content, {
availableSpecs: {},
inheritanceBehavior,
excludedDependencies,
ignoreFailure: true,
versionBehavior: 'exact',
});
}
}
return [];
}
interface ImportmapResolutionOptions {
availableSpecs: Record<string, string>;
excludedDependencies: Array<string>;
versionBehavior: ImportmapVersions;
inheritanceBehavior: ImportmapMode;
ignoreFailure: boolean;
}
async function resolveImportmap(
dir: string,
importmap: Importmap,
options: ImportmapResolutionOptions,
): Promise<Array<SharedDependency>> {
const dependencies: Array<SharedDependency> = [];
const sharedImports = importmap?.imports;
const inheritedImports = importmap?.inherit;
const excludedImports = importmap?.exclude;
const onUnresolved = (name: string, version: string) => {
if (options.ignoreFailure) {
const id = version ? `${name}@${version}` : name;
log('skipUnresolvedDependency_0054', id);
} else {
fail('importMapReferenceNotFound_0027', dir, name);
}
};
if (typeof sharedImports === 'object' && sharedImports) {
for (const depName of Object.keys(sharedImports)) {
const url = sharedImports[depName];
const [assetName, identifier, versionSpec, isAsync] = getDependencyDetails(depName, options.availableSpecs);
if (options.excludedDependencies.includes(identifier)) {
continue;
} else if (typeof url !== 'string') {
log('generalInfo_0000', `The value of "${depName}" in the importmap is not a string and will be ignored.`);
} else if (/^https?:\/\//.test(url)) {
const hash = computeHash(url).substring(0, 7);
dependencies.push({
id: `${identifier}@${hash}`,
requireId: `${identifier}@${hash}`,
entry: url,
name: identifier,
ref: url,
type: 'remote',
isAsync,
});
} else if (url === identifier || shorthandsUrls.includes(url)) {
const entry = tryResolvePackage(identifier, dir);
if (entry) {
const packageJson = await findFile(dirname(entry), 'package.json');
const [realIdentifier, version, requireVersion] = await getLocalDependencyVersion(
packageJson,
depName,
versionSpec,
options.versionBehavior,
);
addLocalDependencies(
dependencies,
realIdentifier,
identifier,
version,
requireVersion,
entry,
assetName,
isAsync,
);
} else {
onUnresolved(identifier, versionSpec);
}
} else if (!url.startsWith('.') && !isAbsolute(url)) {
const entry = tryResolvePackage(url, dir);
if (entry) {
const packageJson = await findFile(dirname(entry), 'package.json');
const [realIdentifier, version, requireVersion] = await getLocalDependencyVersion(
packageJson,
depName,
versionSpec,
options.versionBehavior,
);
addLocalDependencies(
dependencies,
realIdentifier,
identifier,
version,
requireVersion,
entry,
assetName,
isAsync,
);
} else {
onUnresolved(url, versionSpec);
}
} else {
const entry = resolve(dir, url);
const exists = await checkExists(entry);
if (exists) {
const isDirectory = await checkIsDirectory(entry);
const packageJson = isDirectory
? resolve(entry, 'package.json')
: await findFile(dirname(entry), 'package.json');
const packageJsonExists = await checkExists(packageJson);
if (packageJsonExists) {
const [realIdentifier, version, requireVersion] = await getLocalDependencyVersion(
packageJson,
depName,
versionSpec,
options.versionBehavior,
);
addLocalDependencies(
dependencies,
realIdentifier,
identifier,
version,
requireVersion,
isDirectory ? tryResolvePackage(entry, dir) : entry,
assetName,
isAsync,
);
} else if (isDirectory) {
onUnresolved(entry, versionSpec);
} else {
const hash = await getHash(entry);
dependencies.push({
id: `${identifier}@${hash}`,
requireId: `${identifier}@${hash}`,
entry,
name: identifier,
ref: `${assetName}.js`,
type: 'local',
isAsync,
});
}
} else {
onUnresolved(url, versionSpec);
}
}
}
}
if (Array.isArray(inheritedImports)) {
const includedImports = [...options.excludedDependencies, ...dependencies.map((m) => m.name)];
const excluded = Array.isArray(excludedImports) ? [...includedImports, ...excludedImports] : includedImports;
for (const inheritedImport of inheritedImports) {
const otherDependencies = await getInheritedDependencies(
inheritedImport,
dir,
excluded,
options.inheritanceBehavior,
);
for (const dependency of otherDependencies) {
const entry = dependencies.find((dep) => dep.name === dependency.name);
if (!entry) {
dependencies.push({
...dependency,
parents: [inheritedImport],
});
} else if (Array.isArray(entry.parents)) {
entry.parents.push(inheritedImport);
}
}
}
}
return dependencies;
}
async function consumeImportmap(
dir: string,
packageDetails: PiralPackageData,
inherited: boolean,
versionBehavior: ImportmapVersions,
mode: ImportmapMode,
excludedDependencies: Array<string>,
): Promise<Array<SharedDependency>> {
const importmap = packageDetails.importmap;
const appShell = inherited && mode === 'remote';
const availableSpecs = appShell ? (packageDetails.devDependencies ?? {}) : {};
const inheritanceBehavior = appShell ? 'host' : mode;
if (typeof importmap === 'string') {
const notFound = {};
const content = await readJson(dir, importmap, notFound);
if (content === notFound) {
fail('importMapFileNotFound_0028', dir, importmap);
}
const baseDir = dirname(resolve(dir, importmap));
return await resolveImportmap(baseDir, content, {
availableSpecs,
ignoreFailure: inherited,
excludedDependencies,
versionBehavior,
inheritanceBehavior,
});
} else if (typeof importmap === 'undefined' && inherited) {
// Fall back to sharedDependencies or pilets.external if available
const shared: Array<string> = packageDetails.sharedDependencies ?? packageDetails.pilets?.externals;
if (Array.isArray(shared)) {
return shared.map((dep) => ({
id: dep,
name: dep,
entry: dep,
type: 'local',
ref: undefined,
requireId: dep,
}));
}
}
return await resolveImportmap(dir, importmap, {
availableSpecs,
excludedDependencies,
ignoreFailure: inherited,
versionBehavior,
inheritanceBehavior,
});
}
export function readImportmap(
dir: string,
packageDetails: PiralPackageData,
versionBehavior: ImportmapVersions = 'exact',
inheritanceBehavior: ImportmapMode = 'remote',
): Promise<Array<SharedDependency>> {
return consumeImportmap(dir, packageDetails, false, versionBehavior, inheritanceBehavior, []);
}