UNPKG

make-deno-edition

Version:

Automatically makes package.json projects (such as npm packages and node.js modules) compatible with Deno.

669 lines (608 loc) 16.8 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: string[] = [ 'cross-fetch', 'fetch-client', 'fetch-h2', 'fetch-lite', 'isomorphic-fetch', 'isomorphic-unfetch', 'node-fetch', 'unfetch', ] const perms: string[] = [ '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: { [key: string]: 'full' | 'partial' | 'none' } = { 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', } export interface CompatibilityStatus { success: true message: string } export interface Import { /** The type of import: * - internal imports are mapped to their typescript file that should exist within the source directory * - remote imports are assumed to be compatible * - dep imports are mapped to a typescript entry, via manual entry, deno entry, or main entry * - builtin imports are proxied to their deno compat layer if available */ type: null | 'internal' | 'remote' | 'dep' | 'builtin' | 'unnecessary' label: string sourceIndex: number sourceStatement: string sourceTarget: string resultStatement?: string resultTarget?: string name: string entry: string dep?: Dependency path?: string file?: File errors: Set<string> } export type Imports = Import[] export interface File { /** what the file should be referred to as */ label: string /** absolute filesystem path */ path: string /** relative to the package directory */ filename: string /** deno edition path */ denoPath: string /** whether or not the file is necessary or not */ necessary: boolean /** source file content */ source: string /** result file content */ result?: string imports: Imports errors: Set<string> } export interface Files { [path: string]: File } /** package.json dependencies and devDependencies fields */ export interface DependencySemvers { [key: string]: string } export interface Dependency { name: string version: string // json: any denoEntry: string | null unpkg: string esmsh: string errors: Set<string> } export interface Dependencies { [key: string]: Dependency } export interface Details { files: Files deps: Dependencies success: boolean } function replaceImportStatement( sourceStatement: string, sourceTarget: string, resultTarget: string, ) { 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: string, ): [name: string, entry: string] { 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: string, details: Details): File { // prepare const file = details.files[path] // extract imports const matches = file.source.matchAll(importRegExp) for (const match of matches) { const i: Import = { type: null, label: match[1], name: '', entry: '', sourceIndex: match.index!, sourceStatement: match[0], sourceTarget: match[1], errors: new Set<string>(), } // 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 interface MakeOpts { /** The package directory that we will be making a deno edition for */ cwd?: string /** If the entry is incompatible, then fail */ failOnEntryIncompatibility?: boolean /** If any test module is incompatible, then fail */ failOnTestIncompatibility?: boolean /** * If any other module is incompatible, then fail. * This excludes entry, which is governed by {@link failOnEntryIncompatibility} * This excludes tests, which are governed by {@link failOnTestIncompatibility} */ failOnOtherIncompatibility?: boolean /** Whether or not to run deno on the file to verify the conversion is compatible */ run?: boolean } export async function make({ run = true, cwd = process.cwd(), failOnEntryIncompatibility = true, failOnTestIncompatibility = false, failOnOtherIncompatibility = false, }: MakeOpts = {}): Promise<Details> { // paths const pkgPath = join(cwd, 'package.json') const pkg: any = await readJSON(pkgPath).catch((err: any) => Promise.reject(new Errlop('require package.json file to be present', err)), ) // prepare const keywords = new Set<string>(pkg.keywords || []) const denoEditionDirectory = 'edition-deno' const denoEditionPath = join(cwd, denoEditionDirectory) const nm = join(cwd, 'node_modules') // permission args const permArgs: string[] = [] 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: 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: Dependency = { name, version: version as string, 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<string>(), } const path = join(nm, name, 'package.json') try { const pkg: any = 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: boolean let label: string 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: File = { label, path, filename, denoPath: join(denoEditionPath, filename), necessary, source, imports: [], errors: new Set<string>(), } 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: File[] = [] 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: any) { 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: any) => 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: 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() }