make-deno-edition
Version:
Automatically makes package.json projects (such as npm packages and node.js modules) compatible with Deno.
494 lines (493 loc) • 17.4 kB
JavaScript
/* eslint new-cap:0, no-loop-func:0, camelcase:0, no-use-before-define:0 */
// builtin
import { resolve, join, extname, dirname, sep } from 'path';
// external
import list from '@bevry/fs-list';
import remove from '@bevry/fs-remove';
import { isReadable } from '@bevry/fs-readable';
import readFile from '@bevry/fs-read';
import writeFile from '@bevry/fs-write';
import { readJSON, writeJSON } from '@bevry/json';
import Errlop from 'errlop';
import spawn from 'await-spawn';
// local
import * as color from './color.js';
const trim = [
'cross-fetch',
'fetch-client',
'fetch-h2',
'fetch-lite',
'isomorphic-fetch',
'isomorphic-unfetch',
'node-fetch',
'unfetch',
];
const perms = [
'all',
'env',
'hrtime',
'net',
'plugin',
'read',
'run',
'write',
];
// test ground: https://repl.it/@balupton/match-import#index.js
// @todo add tests here instead
export const importRegExp = /^(?:import|export(?! (?:async|function|interface|type|class))) .+? from ['"]([^'"]+)['"]$/gms;
// https://docs.deno.com/runtime/manual/node/compatibility
const builtins = {
assert: 'full',
async_hooks: 'partial',
buffer: 'full',
child_process: 'partial',
cluster: 'none',
console: 'full',
crypto: 'partial',
dgram: 'partial',
diagnostics_channel: 'full',
dns: 'partial',
domain: 'none',
events: 'full',
fs: 'partial',
http: 'partial',
http2: 'partial',
https: 'partial',
inspector: 'partial',
module: 'full',
net: 'partial',
os: 'full',
path: 'full',
perf_hooks: 'partial',
punycode: 'full',
process: 'partial',
querystring: 'full',
readline: 'full',
repl: 'partial',
stream: 'full',
string_decoder: 'partial',
sys: 'full',
test: 'partial',
timers: 'full',
tls: 'partial',
trace_events: 'none',
tty: 'partial',
util: 'full',
url: 'full',
v8: 'partial',
vm: 'partial',
wasi: 'none',
worker_threads: 'partial',
zlib: 'partial',
};
function replaceImportStatement(sourceStatement, sourceTarget, resultTarget) {
if (!resultTarget)
return '';
const parts = sourceStatement.split(' ');
const lastPart = parts.pop();
const replacement = parts
.concat([lastPart.replace(sourceTarget, resultTarget)])
.join(' ');
return replacement;
}
function extractPackageNameAndEntry(input) {
let name = '', entry = '';
// determine it's entry
if (input.includes('/')) {
// custom entry, extract parts
const parts = input.split('/');
name = parts.shift();
// if dep is a scoped package, then include the next part
if (name[0] === '@') {
name += '/' + parts.shift();
}
// remaining parts will be the manual entry
entry = parts.join('/');
}
else {
name = input;
}
// return
return [name, entry];
}
export function convert(path, details) {
// prepare
const file = details.files[path];
// extract imports
const matches = file.source.matchAll(importRegExp);
for (const match of matches) {
const i = {
type: null,
label: match[1],
name: '',
entry: '',
sourceIndex: match.index,
sourceStatement: match[0],
sourceTarget: match[1],
errors: new Set(),
};
// types
if (i.sourceTarget.startsWith('.')) {
i.type = 'internal';
}
else if (i.sourceTarget.startsWith('node:')) {
i.type = 'builtin';
const [name, entry] = extractPackageNameAndEntry(i.sourceTarget.substring(5));
i.name = name;
i.entry = entry;
}
else if (i.sourceTarget.startsWith('npm:')) {
i.type = 'dep';
const [name, entry] = extractPackageNameAndEntry(i.sourceTarget.substring(4));
i.name = name;
i.entry = entry;
}
else if (i.sourceTarget.includes(':') || i.sourceTarget.startsWith('/')) {
i.type = 'remote';
}
else {
// everything else must also be a dependency
i.type = 'dep';
const [name, entry] = extractPackageNameAndEntry(i.sourceTarget);
i.name = name;
i.entry = entry;
}
// handle modifications
if (i.type === 'internal') {
// ensure extension
if (i.sourceTarget.endsWith('/')) {
i.resultTarget = i.sourceTarget + 'index.ts';
}
else {
const ext = extname(i.sourceTarget);
if (ext === '') {
i.resultTarget = i.sourceTarget + '.ts';
}
else if (ext) {
i.resultTarget = i.sourceTarget.replace(ext, '.ts');
}
}
// check the path
i.path = resolve(dirname(path), i.resultTarget);
i.file = details.files[i.path];
if (!i.file) {
i.errors.add(`resolves to [${i.path}] which is not a typescript file inside the source edition`);
}
}
else if (i.type === 'dep') {
const builtin = builtins[i.name] ?? null;
if (builtin) {
// is builtin
i.type = 'builtin';
if (builtin === 'full' || builtin === 'partial') {
// compatible
i.resultTarget = i.entry
? `node:${i.name}/${i.entry}`
: `node:${i.name}`;
}
else {
// incompatible
i.errors.add(`is a node.js builtin that does not yet have a deno compatibility layer`);
}
}
else if (!i.entry && trim.includes(i.name)) {
// is unnecessary
i.type = 'unnecessary';
i.resultTarget = '';
}
else {
// is dependency, check if installed
i.dep = details.deps[i.name];
if (i.dep) {
// use manual entry, then deno entry, then no entry
const entry = i.entry || i.dep.denoEntry || '';
// verify the entry is compatible
if (entry && !entry.endsWith('.ts')) {
// check of i.dep.errors happens later
i.errors.add(`resolved to [${i.name}/${entry}], which does not have the .ts extension`);
}
// if entry, use unpkg, if no entry, use esmsh
i.resultTarget = entry ? i.dep.unpkg + '/' + entry : i.dep.esmsh;
}
else {
// not installed, use npm: prefix
i.resultTarget = `npm:${i.name}`;
}
}
}
// default result target
if (i.resultTarget == null) {
i.resultTarget = i.sourceTarget;
}
// continue
file.imports.push(i);
}
// perform the replacements
let result = file.source;
let offset = 0;
for (const i of file.imports) {
i.label = `${i.type} import of [${i.sourceTarget}] => [${i.resultTarget}]`;
if (i.resultTarget == null) {
// no modification necessary
continue;
}
const cursor = i.sourceIndex + offset;
const replacement = replaceImportStatement(i.sourceStatement, i.sourceTarget, i.resultTarget);
result =
result.substring(0, cursor) +
replacement +
result.substring(cursor + i.sourceStatement.length);
offset += replacement.length - i.sourceStatement.length;
}
// __filename and __dirname ponyfill
if (/__(file|dir)name/.test(result) &&
/__(file|dir)name\s?=/.test(result) === false) {
result =
`import filedirname from '${details.deps.filedirname?.esmsh || 'npm:filedirname'};\n` +
`const [ __filename, __dirname ] = filedirname(import.meta.url);\n` +
result;
}
// apply and return
file.result = result;
return file;
}
export async function make({ run = true, cwd = process.cwd(), failOnEntryIncompatibility = true, failOnTestIncompatibility = false, failOnOtherIncompatibility = false, } = {}) {
// paths
const pkgPath = join(cwd, 'package.json');
const pkg = await readJSON(pkgPath).catch((err) => Promise.reject(new Errlop('require package.json file to be present', err)));
// prepare
const keywords = new Set(pkg.keywords || []);
const denoEditionDirectory = 'edition-deno';
const denoEditionPath = join(cwd, denoEditionDirectory);
const nm = join(cwd, 'node_modules');
// permission args
const permArgs = [];
for (const perm of perms) {
const name = 'allow-' + perm;
if (keywords.has(name)) {
const arg = '--' + name;
permArgs.push(arg);
}
}
// check editions
const sourceEdition = pkg?.editions && pkg.editions[0];
if (!sourceEdition ||
!sourceEdition.tags?.includes('typescript') ||
!sourceEdition.tags?.includes('import')) {
throw new Error('make-deno-edition requires you to define the edition entry for the typescript source code\n' +
'refer to https://github.com/bevry/make-deno-edition and https://editions.bevry.me for details');
}
// get the source edition path
const sourceEditionPath = join(cwd, sourceEdition.directory);
// get the deno entry
const denoEntry = (await isReadable(join(sourceEditionPath, 'deno.ts')))
? 'deno.ts'
: sourceEdition.entry;
// get the source edition files
const paths = (await list(sourceEditionPath))
.filter((path) => path.endsWith('.ts'))
.map((path) => join(sourceEditionPath, path));
// delete the old files
await remove(denoEditionPath);
// prepare details
const details = {
files: {},
deps: {},
success: true,
};
// add the dependencies
for (const [name, version] of Object.entries(Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {}))) {
if (details.deps[name]) {
throw new Error(`[${name}] dependency is duplicated`);
}
else {
const dep = {
name,
version: version,
denoEntry: null,
unpkg: `https://unpkg.com/${name}@${version}`, // compatible with entries, as entire package is available
esmsh: `https://esm.sh/${name}@${version}`, // only supports deno entries it seems
errors: new Set(),
};
const path = join(nm, name, 'package.json');
try {
const pkg = await readJSON(path);
dep.denoEntry = pkg?.deno || null;
}
catch (err) {
// don't change success, as this dependency may not be actually be used
dep.errors.add(`dependency [${name}] does not appear installed, as [${path}] was not valid JSON, install the dependency and try again`);
}
details.deps[dep.name] = dep;
}
}
// add the files
await Promise.all(paths.map(async (path) => {
const filename = path.replace(sourceEditionPath + sep, '');
const source = await readFile(path);
let necessary;
let label;
if (filename === denoEntry) {
necessary = failOnEntryIncompatibility;
label = `entry file [${path}]`;
}
else if (filename.includes('test')) {
necessary = failOnTestIncompatibility;
label = `test file [${path}]`;
}
else {
necessary = failOnOtherIncompatibility;
label = `utility file [${path}]`;
}
label = (necessary ? 'necessary ' : 'optional ') + label;
const file = {
label,
path,
filename,
denoPath: join(denoEditionPath, filename),
necessary,
source,
imports: [],
errors: new Set(),
};
details.files[path] = file;
}));
// convert all the files
for (const path of Object.keys(details.files)) {
convert(path, details);
}
// bubble nested errors
for (const iteration of [1, 2]) {
for (const [path, file] of Object.entries(details.files)) {
for (const i of file.imports) {
// bubble dep import errors
if (i.dep?.errors.size)
i.errors.add(`import of dependency [${i.dep.name}] has incompatibilities`);
// bubble file import errors
if (i.file?.errors.size)
i.errors.add(`import of local file [${i.sourceTarget}] has incompatibilities`);
// bubble import errors
if (i.errors.size)
file.errors.add(`has import incompatibilities`);
}
}
}
// check if we care about the errors or not
for (const file of Object.values(details.files)) {
if (file.errors.size && file.necessary) {
details.success = false;
break;
}
}
// if successful, write the new files
const denoFiles = [];
if (details.success) {
for (const file of Object.values(details.files)) {
// write the successful files only
if (file.errors.size === 0) {
if (file.result == null)
throw new Error('the file had no errors, yet had no content');
await writeFile(file.denoPath, file.result);
denoFiles.push(file);
}
}
}
// attempt to run the successful files
if (run) {
for (const file of denoFiles) {
const args = ['run', ...permArgs, '--reload', '--unstable', file.denoPath];
try {
await spawn('deno', args);
}
catch (err) {
file.errors.add(`running deno on the file failed:\n\tdeno ${args.join(' ')}\n\t` +
String(err.stderr).replace(/\n/g, '\n\t'));
if (file.errors.size && file.necessary) {
details.success = false;
}
}
}
}
// delete deno edition entry, will be re-added later if it is suitable
pkg.editions = pkg.editions.filter((e) => e.directory !== denoEditionDirectory);
// change package.json for success
if (details.success) {
// add deno keywords
keywords.add('deno');
keywords.add('denoland');
keywords.add('deno-entry');
keywords.add('deno-edition');
pkg.keywords = Array.from(keywords).sort();
// add deno edition
const denoEdition = {
description: 'TypeScript source code made to be compatible with Deno',
directory: denoEditionDirectory,
entry: denoEntry,
tags: ['typescript', 'import', 'deno'],
engines: {
deno: true,
browsers: Boolean(pkg.browser),
},
};
pkg.editions.push(denoEdition);
// add deno entry
pkg.deno = join(denoEdition.directory, denoEdition.entry);
// save
writeJSON(pkgPath, pkg);
}
// change package.json for failure
else {
// delete deno keywords
keywords.delete('deno');
keywords.delete('denoland');
keywords.delete('deno-entry');
keywords.delete('deno-edition');
pkg.keywords = Array.from(keywords).sort();
// delete deno entry
delete pkg.deno;
// save
writeJSON(pkgPath, pkg);
}
// return details
return details;
}
export function inform(details, verbose = false) {
for (const path of Object.keys(details.files).sort()) {
const file = details.files[path];
if (file.errors.size) {
if (verbose) {
console.log();
}
if (file.necessary) {
console.log(color.error(file.label, 'failed'));
}
else {
console.log(color.warn(file.label, 'skipped'));
}
if (verbose) {
for (const e of file.errors) {
console.log('↳ ', e);
for (const i of file.imports) {
if (i.errors.size) {
console.log(' ↳ ', i.label);
for (const e of i.errors) {
console.log(' ↳ ', e);
}
}
}
}
}
}
else {
console.log();
console.log(color.success(file.label, 'passed'));
}
}
// console.log('\ndetected dependencies:')
// for (const key of Object.keys(details.deps).sort()) {
// const dep = details.deps[key]
// console.log(color.inspect(dep))
// }
// add dep failures?
console.log();
}