make-deno-edition
Version:
Automatically makes package.json projects (such as npm packages and node.js modules) compatible with Deno.
526 lines (525 loc) • 19.2 kB
JavaScript
;
/* eslint new-cap:0, no-loop-func:0, camelcase:0, no-use-before-define:0 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.inform = exports.make = exports.convert = exports.importRegExp = void 0;
// builtin
const path_1 = require("path");
// external
const fs_list_1 = __importDefault(require("@bevry/fs-list"));
const fs_remove_1 = __importDefault(require("@bevry/fs-remove"));
const fs_readable_1 = require("@bevry/fs-readable");
const fs_read_1 = __importDefault(require("@bevry/fs-read"));
const fs_write_1 = __importDefault(require("@bevry/fs-write"));
const json_1 = require("@bevry/json");
const errlop_1 = __importDefault(require("errlop"));
const await_spawn_1 = __importDefault(require("await-spawn"));
// local
const color = __importStar(require("./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
exports.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];
}
function convert(path, details) {
// prepare
const file = details.files[path];
// extract imports
const matches = file.source.matchAll(exports.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 = (0, path_1.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 = (0, path_1.resolve)((0, path_1.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;
}
exports.convert = convert;
async function make({ run = true, cwd = process.cwd(), failOnEntryIncompatibility = true, failOnTestIncompatibility = false, failOnOtherIncompatibility = false, } = {}) {
// paths
const pkgPath = (0, path_1.join)(cwd, 'package.json');
const pkg = await (0, json_1.readJSON)(pkgPath).catch((err) => Promise.reject(new errlop_1.default('require package.json file to be present', err)));
// prepare
const keywords = new Set(pkg.keywords || []);
const denoEditionDirectory = 'edition-deno';
const denoEditionPath = (0, path_1.join)(cwd, denoEditionDirectory);
const nm = (0, path_1.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 = (0, path_1.join)(cwd, sourceEdition.directory);
// get the deno entry
const denoEntry = (await (0, fs_readable_1.isReadable)((0, path_1.join)(sourceEditionPath, 'deno.ts')))
? 'deno.ts'
: sourceEdition.entry;
// get the source edition files
const paths = (await (0, fs_list_1.default)(sourceEditionPath))
.filter((path) => path.endsWith('.ts'))
.map((path) => (0, path_1.join)(sourceEditionPath, path));
// delete the old files
await (0, fs_remove_1.default)(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 = (0, path_1.join)(nm, name, 'package.json');
try {
const pkg = await (0, json_1.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 + path_1.sep, '');
const source = await (0, fs_read_1.default)(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: (0, path_1.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 (0, fs_write_1.default)(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 (0, await_spawn_1.default)('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 = (0, path_1.join)(denoEdition.directory, denoEdition.entry);
// save
(0, json_1.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
(0, json_1.writeJSON)(pkgPath, pkg);
}
// return details
return details;
}
exports.make = make;
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();
}
exports.inform = inform;