UNPKG

motoko

Version:

Compile and run Motoko smart contracts in Node.js or the browser.

246 lines (229 loc) 7.12 kB
// 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, )}`, ); } }); }