motoko
Version:
Compile and run Motoko smart contracts in Node.js or the browser.
245 lines (228 loc) • 7.11 kB
text/typescript
// Derived from: https://github.com/dfinity/motoko-playground/blob/main/src/workers/file.ts
import { default as parse } from 'parse-github-url';
import fetch from 'cross-fetch';
import { Motoko } from '.';
import sanitize from 'sanitize-filename';
export interface PackageInfo {
name: string;
repo: string;
version: string;
dir?: string;
branch?: string | undefined;
}
export interface Package {
name: string;
version: string;
files: PackageFiles;
}
export type PackageFiles = Record<string, PackageFile>;
export interface PackageFile {
content: string;
}
function parseGithubPackageInfo(path: string | PackageInfo): PackageInfo {
if (!path) {
return;
}
if (typeof path === 'object') {
return path;
}
let result;
try {
result = parse(path);
if (!result) {
return;
}
} catch (err) {
// console.warn(err);
return;
}
const { name, filepath, branch, owner } = result;
return {
name,
repo: `https://github.com/${owner}/${name}.git`,
version: branch,
dir: filepath,
branch,
// homepage: ,
};
}
async function fetchPackageFiles(
info: PackageInfo,
): Promise<PackageFiles | undefined> {
const prefix = 'https://github.com/';
const suffix = '.git';
if (!info.repo.startsWith(prefix) || !info.repo.endsWith(suffix)) {
return;
}
const repoPart = info.repo.slice(prefix.length, -suffix.length);
const possiblyCDN = !(
(info.branch &&
info.branch.length % 2 === 0 &&
/^[A-F0-9]+$/i.test(info.branch)) ||
info.branch === 'master' ||
info.branch === 'main'
);
if (possiblyCDN) {
try {
const result = await fetchFromService(
info,
'CDN',
`https://data.jsdelivr.com/v1/package/gh/${repoPart}@${info.branch}/flat`,
`https://cdn.jsdelivr.net/gh/${repoPart}@${info.branch}`,
'files',
'name',
);
if (result) {
return result;
}
} catch (err) {
console.error('[CDN]', err);
}
}
return await fetchFromService(
info,
'GitHub',
`https://api.github.com/repos/${repoPart}/git/trees/${info.branch}?recursive=1`,
`https://raw.githubusercontent.com/${repoPart}/${info.branch}/`,
'tree',
'path',
(file) => file.type === 'blob',
);
}
async function fetchFromService(
info: PackageInfo,
serviceName: string,
metaUrl: string,
baseUrl: string,
resultProperty: string,
pathProperty: string,
condition?: (file: any) => boolean,
): Promise<PackageFiles | undefined> {
const response = await fetch(metaUrl);
if (!response.ok) {
throw Error(
response.statusText ||
`Could not fetch from ${serviceName}: ${info.repo}`,
);
}
const json = await response.json();
if (!json.hasOwnProperty(resultProperty)) {
throw new Error(`Unexpected response from ${serviceName}`);
}
// Remove leading and trailing '/' from directory
let directory = info.dir
? info.dir.replace(/^\//, '').replace(/\/$/, '')
: '';
const files: Record<string, PackageFile> = {};
await Promise.all(
(<any[]>json[resultProperty])
.filter((file) => {
return (
(!directory ||
file[pathProperty].startsWith(
file[pathProperty].startsWith('/')
? `/${directory}`
: directory,
)) &&
(!condition || condition(file)) &&
/\.mo$/.test(file[pathProperty])
);
})
.map(async (file) => {
const response = await fetch(`${baseUrl}${file[pathProperty]}`);
if (!response.ok) {
throw Error(response.statusText);
}
const content = await response.text();
let path = file[pathProperty];
if (path.startsWith('/')) {
path = path.slice(1);
}
if (directory) {
// Remove directory prefix
path = path.slice(directory.length + 1);
}
files[path] = {
content,
};
}),
);
return files;
}
export async function fetchPackage(
name: string,
info: string | PackageInfo,
): Promise<Package | undefined> {
if (typeof info === 'string') {
info = parseGithubPackageInfo(info);
}
const files = await fetchPackageFiles(info);
if (!files) {
return;
}
return {
name,
version: info.version,
files,
};
}
export async function installPackages(
mo: Motoko,
packages: Record<string, string | PackageInfo>,
) {
await Promise.all(
Object.entries(packages).map(async ([name, info]) => {
const pkg = await fetchPackage(name, info);
mo.loadPackage(pkg);
return pkg;
}),
);
}
export function validatePackage(pkg: Package) {
function showValue(value: any) {
const string = JSON.stringify(value);
return string.length > 50 ? string.substring(0, 50) + '...' : string;
}
function getPackageDisplayName() {
return `(${pkg.name} / ${pkg.version})`;
}
if (typeof pkg !== 'object' || Array.isArray(pkg)) {
throw new Error(`Unexpected package: ${showValue(pkg)}`);
}
if (typeof pkg.name !== 'string' || sanitize(pkg.name) !== pkg.name) {
throw new Error(`Invalid package name ${getPackageDisplayName()}`);
}
if (
typeof pkg.version !== 'string' ||
sanitize(pkg.version) !== pkg.version
) {
throw new Error(`Invalid package version ${getPackageDisplayName()}`);
}
if (typeof pkg.files !== 'object' || Array.isArray(pkg.files)) {
throw new Error(`Invalid package files: ${showValue(pkg.files)}`);
}
Object.entries(pkg.files).forEach(([path, file]) => {
if (
typeof path !== 'string' ||
path.split('/').some((p) => sanitize(p) !== p)
) {
throw new Error(
`Invalid file path ${getPackageDisplayName()} [${path}]`,
);
}
if (typeof file !== 'object' || Array.isArray(file)) {
throw new Error(
`Invalid file ${getPackageDisplayName()} [${path}]: ${showValue(
file,
)}`,
);
}
if (typeof file.content !== 'string') {
throw new Error(
`Invalid file content ${getPackageDisplayName()} [${path}]: ${showValue(
file.content,
)}`,
);
}
});
}