UNPKG

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
/* 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(); }