denoify
Version:
For NPM module authors that would like to support Deno but do not want to write and maintain a port.
638 lines (516 loc) • 23.4 kB
text/typescript
import { ModuleAddress } from "../types/ModuleAddress";
import { is404 } from "../../tools/is404";
import { urlJoin } from "../../tools/urlJoin";
import { getGithubDefaultBranchName } from "get-github-default-branch-name";
import { getThirdPartyDenoModuleInfos } from "../getThirdPartyDenoModuleInfos";
import fetch from "node-fetch";
import * as commentJson from "comment-json";
import * as path from "path";
import { getCurrentStdVersion } from "../getCurrentStdVersion";
import type { getInstalledVersionPackageJsonFactory } from "../getInstalledVersionPackageJson";
import { addCache } from "../../tools/addCache";
import { toPosix } from "../../tools/toPosix";
import { id } from "tsafe";
import { getLatestTag } from "../../tools/githubTags";
import { isInsideOrIsDir } from "../../tools/isInsideOrIsDir";
import { knownPorts } from "./knownPorts";
import { assert } from "tsafe/assert";
import { exclude } from "tsafe/exclude";
import getFileTypeAndContent from "../config/fileAndContent";
import { parseAsDenoifyConfig } from "../config/parseParams";
import { fetchWithTimeout } from "../../tools/fetchWithTimeout";
type GetValidImportUrl = (
params:
| {
target: "DEFAULT EXPORT";
}
| {
target: "SPECIFIC FILE";
specificImportPath: string; // e.g tools/typeSafety ( no .ts ext )
}
) => Promise<string>;
type Result =
| {
result: "SUCCESS";
getValidImportUrl: GetValidImportUrl;
}
| {
result: "UNKNOWN BUILTIN";
};
export function resolveNodeModuleToDenoModuleFactory(
params: {
userProvidedPorts: { [nodeModuleName: string]: string };
dependencies: { [nodeModuleName: string]: string };
devDependencies: { [nodeModuleName: string]: string };
log: typeof console.log;
} & ReturnType<typeof getInstalledVersionPackageJsonFactory>
) {
const { log, getInstalledVersionPackageJson } = params;
const { denoPorts } = (() => {
const denoPorts: { [nodeModuleName: string]: string } = {};
[knownPorts.third_party, knownPorts.builtins, params.userProvidedPorts].forEach(record =>
Object.keys(record).forEach(nodeModuleName => (denoPorts[nodeModuleName] = record[nodeModuleName]))
);
return { denoPorts };
})();
const allDependencies = {
...params.dependencies,
...params.devDependencies
};
const isInUserProvidedPort = (nodeModuleName: string) => nodeModuleName in params.userProvidedPorts;
const resolveNodeModuleToDenoModule = addCache(async (params: { nodeModuleName: string }): Promise<Result> => {
const {
nodeModuleName //js-yaml
} = params;
walk: {
if (nodeModuleName in allDependencies) {
break walk;
}
if (!(nodeModuleName in denoPorts)) {
return { "result": "UNKNOWN BUILTIN" };
}
const getValidImportUrlFactoryResult = await getValidImportUrlFactory({
"moduleAddress": ModuleAddress.parse(denoPorts[nodeModuleName]),
"desc": "NOT LISTED AS A DEPENDENCY (PROBABLY NODE BUILTIN)"
});
if (!getValidImportUrlFactoryResult.couldConnect) {
return { "result": "UNKNOWN BUILTIN" };
}
const { getValidImportUrl } = getValidImportUrlFactoryResult;
return {
"result": "SUCCESS",
getValidImportUrl
};
}
let gitHubRepo: ModuleAddress.GitHubRepo | undefined = undefined;
if (ModuleAddress.GitHubRepo.match(allDependencies[nodeModuleName])) {
/*
If we are here then:
allDependencies[nodeModuleName] === "github:garronej/ts-md5#1.2.7"
else:
allDependencies[nodeModuleName] === "^1.2.3"
*/
gitHubRepo = ModuleAddress.GitHubRepo.parse(allDependencies[nodeModuleName]);
}
const {
version, // 3.13.1 (version installed)
repository: repositoryEntryOfPackageJson
} = await getInstalledVersionPackageJson({ nodeModuleName }).catch(() => {
log(
[
`${nodeModuleName} could not be found in the node_module directory`,
`seems like you needs to re-install your project dependency ( npm install )`
].join(" ")
);
process.exit(-1);
});
if (gitHubRepo === undefined) {
gitHubRepo = (() => {
const repositoryUrl = repositoryEntryOfPackageJson?.["url"];
if (!repositoryUrl) {
return undefined;
}
const [repositoryName, userOrOrg] = repositoryUrl
.replace(/\.git$/i, "")
.split("/")
.filter((s: string) => !!s)
.reverse();
if (!repositoryName || !userOrOrg) {
return undefined;
}
return ModuleAddress.GitHubRepo.parse(`github:${userOrOrg}/${repositoryName}`);
})();
}
walk: {
if (!(nodeModuleName in denoPorts)) {
break walk;
}
const getValidImportUrlFactoryResult = await getValidImportUrlFactory({
"moduleAddress": ModuleAddress.parse(denoPorts[nodeModuleName]),
"desc": "MATCH VERSION INSTALLED IN NODE_MODULES",
version
});
if (!getValidImportUrlFactoryResult.couldConnect) {
log([
`WARNING: Even if the port ${denoPorts[nodeModuleName]}`,
`was specified for ${nodeModuleName} we couldn't connect to the repo`
]);
break walk;
}
const { versionFallbackWarning, getValidImportUrl } = getValidImportUrlFactoryResult;
if (versionFallbackWarning) {
log(versionFallbackWarning);
}
return {
"result": "SUCCESS",
getValidImportUrl
};
}
walk: {
if (gitHubRepo === undefined) {
break walk;
}
const getValidImportUrlFactoryResult = await getValidImportUrlFactory({
"moduleAddress": gitHubRepo,
"desc": "MATCH VERSION INSTALLED IN NODE_MODULES",
version
});
if (!getValidImportUrlFactoryResult.couldConnect) {
break walk;
}
const { versionFallbackWarning, getValidImportUrl } = getValidImportUrlFactoryResult;
if (versionFallbackWarning) {
log(versionFallbackWarning);
}
if (isInUserProvidedPort(nodeModuleName)) {
log([`NOTE: ${nodeModuleName} is a denoified module,`, `there is no need for an entry for in package.json denoPorts`].join(" "));
}
return {
result: "SUCCESS",
getValidImportUrl
};
}
return {
"result": "SUCCESS",
"getValidImportUrl": params =>
Promise.resolve(
`npm:${nodeModuleName}@${version}${(() => {
switch (params.target) {
case "DEFAULT EXPORT":
return "";
case "SPECIFIC FILE":
return `/${params.specificImportPath}`;
}
})()}`
)
};
});
return { resolveNodeModuleToDenoModule };
}
export type ValidImportUrlResult =
| {
couldConnect: false;
}
| {
couldConnect: true;
versionFallbackWarning: string | undefined;
getValidImportUrl: GetValidImportUrl;
};
/** Exported only for tests purpose */
export const { getValidImportUrlFactory } = (() => {
type Params = {
moduleAddress: ModuleAddress;
} & (
| {
desc: "NOT LISTED AS A DEPENDENCY (PROBABLY NODE BUILTIN)";
}
| {
desc: "MATCH VERSION INSTALLED IN NODE_MODULES";
version: string;
}
);
/**
* Perform no check, just synchronously assemble the url
* from a ModuleAddress, a branch and a path to file.
* */
function buildUrlFactory(params: { moduleAddress: ModuleAddress }) {
const { moduleAddress } = params;
const buildUrl = ((): ((
candidateBranch: string, //e.g: deno_latest
pathToFile: string //e.g: tools/typeSafety/assert.ts
) => string) => {
switch (moduleAddress.type) {
case "GITHUB REPO":
return (candidateBranch, pathToFile) =>
urlJoin(
"https://raw.githubusercontent.com",
moduleAddress.userOrOrg,
moduleAddress.repositoryName,
candidateBranch,
toPosix(pathToFile)
);
case "DENO.LAND URL":
return (candidateBranch, pathToFile) =>
urlJoin([moduleAddress.baseUrlWithoutBranch.replace(/\/$/, ""), `@${candidateBranch}`].join(""), toPosix(pathToFile));
case "GITHUB-RAW URL":
return (candidateBranch, pathToFile) =>
urlJoin(moduleAddress.baseUrlWithoutBranch.replace(/\/$/, ""), candidateBranch, toPosix(pathToFile));
}
})();
return { buildUrl };
}
async function* candidateBranches(params: Params): AsyncGenerator<[string, false | { version: string }]> {
const { moduleAddress } = params;
let fallback: false | { version: string } = false;
if (moduleAddress.type === "DENO.LAND URL" && moduleAddress.isStd) {
yield [await getCurrentStdVersion(), fallback];
return undefined;
}
if (params.desc === "MATCH VERSION INSTALLED IN NODE_MODULES") {
const { version } = params;
yield ["v" + version, fallback];
yield [version, fallback];
fallback = { version };
}
if (moduleAddress.branch !== undefined) {
yield [moduleAddress.branch, fallback];
}
switch (moduleAddress.type) {
case "GITHUB-RAW URL":
return undefined;
case "GITHUB REPO":
const latestTag = await getLatestTag({
"owner": moduleAddress.userOrOrg,
"repo": moduleAddress.repositoryName
});
if (latestTag !== undefined) {
yield [latestTag, fallback];
}
yield [
await getGithubDefaultBranchName({
"owner": moduleAddress.userOrOrg,
"repo": moduleAddress.repositoryName
}),
fallback
];
break;
case "DENO.LAND URL":
if (moduleAddress.branch !== undefined) {
break;
}
if (moduleAddress.isStd) {
yield [
await getGithubDefaultBranchName({
"owner": "denoland",
"repo": "deno"
}),
fallback
];
} else {
const latestVersion = (
await getThirdPartyDenoModuleInfos({
"denoModuleName": moduleAddress.baseUrlWithoutBranch.split("/").reverse()[0]
})
)?.latestVersion;
if (latestVersion === undefined) {
break;
}
yield [latestVersion, fallback];
}
break;
}
}
const getTsconfigOutDir = addCache(async (params: { moduleAddress: ModuleAddress.GitHubRepo; gitTag: string }): Promise<string | undefined> => {
const { moduleAddress, gitTag } = params;
const { buildUrl } = buildUrlFactory({ moduleAddress });
const tsconfigJson = await fetch(buildUrl(gitTag, "tsconfig.json")).then(
res => (`${res.status}`.startsWith("2") ? res.text() : undefined),
() => undefined
);
if (tsconfigJson === undefined) {
return undefined;
}
const outDir: string | undefined = commentJson.parse(tsconfigJson)["compilerOptions"]?.["outDir"];
if (typeof outDir !== "string") {
return undefined;
}
return path.normalize(toPosix(outDir));
});
const { getDenoifyOutDir } = (() => {
const getExplicitDenoifyOutDir = addCache(async (params: { gitTag: string; moduleAddress: ModuleAddress.GitHubRepo }) => {
const { gitTag, moduleAddress } = params;
const { buildUrl } = buildUrlFactory({ moduleAddress });
const denoifyOut = parseAsDenoifyConfig({
"configFileType": await getFileTypeAndContent({
"getConfigFileRawContent": async file =>
await fetch(buildUrl(gitTag, file)).then(
res => (`${res.status}`.startsWith("2") ? res.text() : undefined),
() => undefined
)
})
})?.out;
if (denoifyOut === undefined) {
return undefined;
}
return path.normalize(toPosix(denoifyOut));
});
const getDenoifyOutDir = addCache(
async (params: {
//TODO: Through out the codebase we name "branch" what are better described by tags.
//Maybe we should use the consecrated therm "targetCommitish"
gitTag: string;
moduleAddress: ModuleAddress.GitHubRepo;
}) => {
const { gitTag, moduleAddress } = params;
explicitely_specified: {
const denoifyOutDir = await getExplicitDenoifyOutDir({ moduleAddress, gitTag });
if (denoifyOutDir === undefined) {
break explicitely_specified;
}
return denoifyOutDir;
}
default_based_on_tsconfig_outDir: {
const tsconfigOutDir = await getTsconfigOutDir({ moduleAddress, gitTag });
if (tsconfigOutDir === undefined) {
break default_based_on_tsconfig_outDir;
}
return path.join(
path.dirname(tsconfigOutDir), // .
`deno_${path.basename(tsconfigOutDir)}` //deno_dist
); // deno_dist
}
return undefined;
}
);
return { getDenoifyOutDir };
})();
async function resolveVersion(params: Params) {
const { moduleAddress } = params;
const { buildUrl } = buildUrlFactory({ moduleAddress });
for await (const [candidateBranch, fallback] of candidateBranches(params)) {
let indexUrl: string;
switch (moduleAddress.type) {
case "DENO.LAND URL":
case "GITHUB-RAW URL": {
indexUrl = buildUrl(candidateBranch, moduleAddress.pathToIndex);
if (!(await is404(indexUrl))) {
break;
}
continue;
}
case "GITHUB REPO": {
const denoifyOutDir = await getDenoifyOutDir({ moduleAddress, "gitTag": candidateBranch });
if (denoifyOutDir === undefined) {
continue;
}
indexUrl = buildUrl(candidateBranch, path.join(denoifyOutDir, "mod.ts"));
if (await is404(indexUrl)) {
continue;
}
break;
}
}
return {
"branchForVersion": candidateBranch,
"versionFallbackWarning": !fallback
? undefined
: `Can't lookup version ${fallback.version} for module ${JSON.stringify(moduleAddress)}, falling back to ${candidateBranch}`,
indexUrl
};
}
return undefined;
}
const getValidImportUrlFactory = addCache(async (params: Params): Promise<ValidImportUrlResult> => {
const { moduleAddress } = params;
const { buildUrl } = buildUrlFactory({ moduleAddress });
const versionResolutionResult = await resolveVersion(params);
if (versionResolutionResult === undefined) {
return { "couldConnect": false };
}
const { branchForVersion, versionFallbackWarning, indexUrl } = versionResolutionResult;
const denoifyOutDir =
moduleAddress.type !== "GITHUB REPO" ? undefined : await getDenoifyOutDir({ moduleAddress, "gitTag": branchForVersion });
const getValidImportUrl = addCache(
id<GetValidImportUrl>(async params => {
if (params.target === "DEFAULT EXPORT") {
return indexUrl;
}
const { specificImportPath } = params;
let url = await (async () => {
const pathToFile = await (async () => {
switch (moduleAddress.type) {
case "DENO.LAND URL":
return moduleAddress.isStd
? `${moduleAddress.pathToIndex.replace(/\.ts$/, "")}/${specificImportPath}.ts`
: `${specificImportPath}.ts`;
case "GITHUB-RAW URL":
return `${specificImportPath}.ts`;
case "GITHUB REPO":
//NOTE: resolveVersion was successful so we can assert that:
assert(denoifyOutDir !== undefined);
const tsconfigOutDir = await getTsconfigOutDir({ moduleAddress, "gitTag": branchForVersion });
return (
path.join(
denoifyOutDir, //deno_dist
tsconfigOutDir !== undefined &&
isInsideOrIsDir({ "dirPath": tsconfigOutDir, "fileOrDirPath": specificImportPath })
? path.relative(
tsconfigOutDir, // dist
specificImportPath // dist/tools/typeSafety
) // tools/typeSafety
: specificImportPath // tools/typeSafety ( when using enable short path )
) + // deno_dist/tool/typeSafety
".ts"
); // deno_dist/tool/typeSafety.ts
}
})();
return buildUrl(branchForVersion, pathToFile);
})();
walk: {
if (await is404(url)) {
break walk;
}
return url;
}
url = url.replace(/\.ts$/, "/index.ts");
// https://.../deno_dist/tool/typeSafety/index.ts
walk: {
if (await is404(url)) {
break walk;
}
return url;
}
throw new Error(`Can't locate ${specificImportPath} from ${JSON.stringify(moduleAddress)}`);
})
);
const rawGitHubUserContentUrlToDenoLandXUrl = addCache(
async (params: { validRawGitHubUserContentUrl: string; denoifyOutDir: string }): Promise<string> => {
const { validRawGitHubUserContentUrl, denoifyOutDir } = params;
// https://raw.githubusercontent.com/garronej/tsafe/v0.10.1/deno_dist/assert.ts;
// https://deno.land/x/tsafe@v0.10.1/assert.ts
const regExpMatchArray = validRawGitHubUserContentUrl.match(
/^https:\/\/raw\.githubusercontent\.com\/[^\/]+\/([^\/]+)+\/([^\/]+)\/(.*)$/
);
assert(regExpMatchArray !== null);
const [, repoName, version, fullPathToFile] = regExpMatchArray;
for (const moduleName of [repoName.replace(/-/g, "_"), repoName.replace(/-/g, "")]) {
const buildDenoLandXUrl = (pathToFile: string) => urlJoin("https://deno.land/x", `${moduleName}@${version}`, toPosix(pathToFile));
for (const pathToFile of [
isInsideOrIsDir({ "dirPath": denoifyOutDir, "fileOrDirPath": fullPathToFile })
? path.relative(denoifyOutDir, fullPathToFile)
: undefined,
fullPathToFile
].filter(exclude(undefined))) {
const denoLandXUrl = buildDenoLandXUrl(pathToFile);
const fetchResponse = await fetchWithTimeout(denoLandXUrl, { "timeout": 8000 });
if (fetchResponse.status === 404 || fetchResponse.status === 400) {
continue;
}
const [denoLandXRawFileContent, gitHubUserContentFileContent] = await Promise.all([
fetchResponse.text(),
fetch(validRawGitHubUserContentUrl).then(resp => resp.text())
]);
if (denoLandXRawFileContent !== gitHubUserContentFileContent) {
continue;
}
return denoLandXUrl;
}
}
return validRawGitHubUserContentUrl;
}
);
return {
"couldConnect": true,
versionFallbackWarning,
"getValidImportUrl":
moduleAddress.type !== "GITHUB REPO"
? getValidImportUrl
: async params =>
rawGitHubUserContentUrlToDenoLandXUrl({
"validRawGitHubUserContentUrl": await getValidImportUrl(params),
"denoifyOutDir": (assert(denoifyOutDir !== undefined), denoifyOutDir)
})
};
});
return { getValidImportUrlFactory };
})();