diff --git a/index.ts b/index.ts index a091125..afe0a89 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,7 @@ import { spawn } from 'child_process' import { mkdir, readFile, writeFile, stat, utimes } from 'fs/promises' import dbg from 'debug' import * as path from 'path' +import { getMatcher, normalize } from './util' const debug = dbg('@tradle/lambda-plugins') @@ -199,49 +200,24 @@ async function assertInstalled (plugins: FNOrResult, { tmpDir, maxAge } } -interface Cause { - useImport: boolean - cause: string -} - -const CAUSE_FALLBACK: Cause = { cause: 'require', useImport: false } -const CAUSE_TYPE: Cause = { cause: 'import because of type=module', useImport: true } -const CAUSE_MODULE: Cause = { cause: 'import because of a defined module', useImport: true } -const CAUSE_DOT_EXPORT: Cause = { cause: 'import because of exports["."]', useImport: true } -const CAUSE_DOT_ANY: Cause = { cause: 'import because of exports["./*"]', useImport: true } - -function fuzzyChooseImport (pkg: any): Cause { - if (pkg.type === 'module') return CAUSE_TYPE - if (pkg.module !== undefined) return CAUSE_MODULE - if (typeof pkg.exports === 'object' && pkg.exports !== null) { - if (pkg.exports['.']?.import !== undefined) { - return CAUSE_DOT_EXPORT - } - if (pkg.exports['./*']?.import !== undefined) { - return CAUSE_DOT_ANY - } - } - return CAUSE_FALLBACK -} - -async function loadData (name: string, depPath: string, pkg: any): Promise { - const { cause, useImport } = fuzzyChooseImport(pkg) - debug('Loading package for %s from %s (%s)', name, depPath, cause) - return useImport ? await import(depPath) : require(depPath) -} - async function loadPackage (name: string, depPath: string): Promise { const pkgPath = path.join(depPath, 'package.json') - debug('Loading package.json for %s from %s', name, pkgPath) - const data = await readFile(pkgPath, 'utf-8') - return JSON.parse(data) + let raw = '{}' + try { + raw = await readFile(pkgPath, 'utf-8') + debug('Using package.json for %s from %s', name, pkgPath) + } catch (err) { + // Package json is optional + debug('No package.json found at %s, using regular lookup', pkgPath) + } + return JSON.parse(raw) } export class Plugin { readonly name: string readonly path: string - #data: Promise | undefined + #data: { [child: string]: Promise } | undefined #pkg: Promise | undefined constructor (name: string, path: string) { @@ -259,15 +235,29 @@ export class Plugin { return pkg } - /* eslint-disable-next-line @typescript-eslint/promise-function-async */ - data (opts?: { force?: boolean }): Promise { - let data = this.#data - if (data === undefined || opts?.force !== true) { - /* eslint-disable-next-line @typescript-eslint/promise-function-async */ - data = this.package(opts).then(pkg => loadData(this.name, this.path, pkg)) - this.#data = data + async #loadData (child: string, force: boolean): Promise { + const pkg = await this.package({ force }) + const matcher = getMatcher(this.path, pkg) + const mjs = matcher(child, 'module') + /* eslint-disable-next-line @typescript-eslint/prefer-optional-chain */ + if (mjs !== undefined && mjs.location !== null) { + debug('Importing package for %s from %s (%s)', this.name, mjs.location, mjs.cause) + return await import(mjs.location) } - return data + const cjs = matcher(child, 'commonjs') + /* eslint-disable-next-line @typescript-eslint/prefer-optional-chain */ + if (cjs !== undefined && cjs.location !== null) { + debug('Requiring package for %s from %s (%s)', this.name, cjs.location, cjs.cause) + return require(cjs.location) + } + throw new Error(`Can not require or import a package for ${this.name} at ${this.path}`) + } + + /* eslint-disable-next-line @typescript-eslint/promise-function-async */ + data (opts?: { force?: boolean, child?: string }): Promise { + const all = this.#data ?? (this.#data = {}) + const child = normalize(opts?.child) + return all[child] ?? (all[child] = this.#loadData(child, opts?.force ?? false)) } } diff --git a/package.json b/package.json index 98e590b..255ceba 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "bin": "./bin/lambda-plugins", "scripts": { "prepare": "npm run build", - "build": "tsc && tsc -p tsconfig.cjs.json", + "build": "tsc -p tsconfig.mjs.json && tsc -p tsconfig.cjs.json", "lint": "ts-standard", + "unit": "c8 ts-node test/*.test.ts", "test": "npm run lint" }, "ts-standard": { @@ -21,6 +22,9 @@ "devDependencies": { "@types/debug": "^4.1.7", "@types/node": "^17.0.16", + "c8": "^7.11.0", + "fresh-tape": "^5.5.0", + "ts-node": "^10.5.0", "ts-standard": "^11.0.0", "typescript": "^4.4.4" }, diff --git a/test/util.test.ts b/test/util.test.ts new file mode 100644 index 0000000..4db32e5 --- /dev/null +++ b/test/util.test.ts @@ -0,0 +1,84 @@ +import { createMatcher, getMatcher } from '../util' +import * as test from 'fresh-tape' + +test('repeat, on-demand matcher', async t => { + const pkg = { main: 'index.js' } + const matcher = getMatcher('/root', pkg) + t.deepEqual(matcher('.', 'commonjs'), { cause: '.main', location: '/root/index.js' }) + t.equal(getMatcher('/root', pkg), matcher) +}) +test('simple main', async t => { + const match = createMatcher('/root', { main: './test.js' }) + for (const input of [ + '', + '.', + './' + ]) { + t.deepEqual(match(input, 'commonjs'), { cause: '.main', location: '/root/test.js' }) + } + t.equal(match('', 'module'), undefined) +}) +test('export override string', async t => { + const a = createMatcher('/root', { main: 'a.js', exports: './b.js' }) + t.deepEqual(a('', 'commonjs'), { cause: '.exports[\'.\']', location: '/root/b.js' }) + t.deepEqual(a('', 'module'), { cause: '.exports[\'.\']', location: '/root/b.js' }) +}) +test('export override object', async t => { + const a = createMatcher('/root', { main: 'a.js', exports: { import: './b.js', require: './c.js' } }) + t.deepEqual(a('', 'commonjs'), { cause: '.exports[\'.\'].require', location: '/root/c.js' }) + t.deepEqual(a('', 'module'), { cause: '.exports[\'.\'].import', location: '/root/b.js' }) +}) +test('export deep override', async t => { + const match = createMatcher('/root', { + main: 'a.js', + exports: { + '.': { require: './b.js', import: './c.js' }, + './c-a': { require: './d.js', import: './e.js' }, + './c-b': { default: './f.js' }, + './c-c': { node: './g.js', require: './h.js', default: './i.js' } + } + }) + t.deepEqual(match('c-a', 'commonjs'), { cause: '.exports[\'./c-a\'].require', location: '/root/d.js' }) + t.equals(match('c-a.js', 'commonjs'), undefined) + t.deepEqual(match('c-a', 'module'), { cause: '.exports[\'./c-a\'].import', location: '/root/e.js' }) + t.deepEqual(match('c-b', 'commonjs'), { cause: '.exports[\'./c-b\'].default', location: '/root/f.js' }) + t.deepEqual(match('c-b', 'module'), { cause: '.exports[\'./c-b\'].default', location: '/root/f.js' }) + t.deepEqual(match('c-c', 'commonjs'), { cause: '.exports[\'./c-c\'].node', location: '/root/g.js' }) + t.deepEqual(match('c-c', 'module'), { cause: '.exports[\'./c-c\'].default', location: '/root/i.js' }) +}) +test('pattern', async t => { + const match = createMatcher('/root', { + main: 'a.js', + exports: { + './foo/*': null, + './bar/*': { require: null }, + '.': { require: './b.js', import: './c.js' }, + './bak/*.ts': { require: './cjs/*.js' }, + './*': { require: './cjs/*.js', import: './mjs/*' } + } + }) + t.deepEqual(match('c-a', 'commonjs'), { cause: '.exports[\'./*\'].require', location: '/root/cjs/c-a.js' }) + t.deepEqual(match('foo/c-a', 'commonjs'), { cause: '.exports[\'./foo/*\']', location: null }) + t.deepEqual(match('bar/c-a', 'commonjs'), { cause: '.exports[\'./bar/*\'].require', location: null }) + t.deepEqual(match('baz/c-a', 'module'), { cause: '.exports[\'./*\'].import', location: '/root/mjs/baz/c-a' }) + t.deepEqual(match('bak/d.ts', 'commonjs'), { cause: '.exports[\'./bak/*.ts\'].require', location: '/root/cjs/d.js' }) +}) +test('deep require', async t => { + const deep = createMatcher('/root', { exports: { './test': './a.js' } }) + t.deepEqual(deep('test', 'commonjs'), { cause: '.exports[\'./test\']', location: '/root/a.js' }) +}) +test('main and type=module', async t => { + const match = createMatcher('/root', { main: './test.js', type: 'module' }) + t.equal(match('', 'commonjs'), undefined) + t.deepEqual(match('', 'module'), { cause: '.main and .type=module', location: '/root/test.js' }) +}) +test('module', async t => { + const match = createMatcher('/root', { main: './a.cjs', module: 'b.mjs' }) + t.deepEqual(match('', 'commonjs'), { cause: '.main', location: '/root/a.cjs' }) + t.deepEqual(match('', 'module'), { cause: '.module', location: '/root/b.mjs' }) +}) +test.skip('empty match', async t => { + const match = createMatcher('/root', {}) + t.deepEqual(match('', 'commonjs'), { cause: 'fs', location: '/root/indexs.js' }) + t.equal(match('', 'module'), undefined) +}) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 28deb4d..5906aeb 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -1,7 +1,6 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "module": "commonjs", "outDir": "cjs" } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 5482690..419aa38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,13 +5,13 @@ "strictNullChecks": true, "sourceMap": true, "strict": true, - "module": "es2020", + "module": "commonjs", "strictFunctionTypes": true, "forceConsistentCasingInFileNames": true, "target": "ES2017", "moduleResolution": "node", "declaration": true, "lib": ["es2018"], - "outDir": "./mjs", + "outDir": ".work", } } diff --git a/tsconfig.mjs.json b/tsconfig.mjs.json new file mode 100644 index 0000000..8d46afa --- /dev/null +++ b/tsconfig.mjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "es2020", + "outDir": "mjs" + } +} \ No newline at end of file diff --git a/util.ts b/util.ts new file mode 100644 index 0000000..ada71ee --- /dev/null +++ b/util.ts @@ -0,0 +1,220 @@ +import * as path from 'path' + +interface Package { + main?: string + type?: string + module?: string + exports?: Definition | { [pattern: string]: Definition } +} + +function isDefinition (map: any): map is Definition { + if (map === 'string') { + return true + } + if (typeof map !== 'object' || map === null) { + return false + } + return ( + 'default' in map || + 'import' in map || + 'node' in map || + 'require' in map + ) +} + +type Definition = string | null | { + default?: string | null + 'import'?: string | null + node?: string | null + require?: string | null +} + +interface Cause { + cause: string +} + +interface Import extends Cause { + location: string | null +} + +type Type = 'module' | 'commonjs' +type Matcher = (path: string, type: Type) => Import | undefined + +function createLegacyMatcher (pkgPth: string, pkg: Package): Matcher { + const pkgType = pkg.type === 'module' ? 'module' : 'commonjs' + return (name, type) => { + if (pkg.main !== undefined && type === pkgType) { + return { + cause: type === 'module' ? '.main and .type=module' : '.main', + location: path.resolve(pkgPth, pkg.main) + } + } + if (type === 'module' && pkg.module !== undefined) { + return { + cause: '.module', + location: path.resolve(pkgPth, pkg.module) + } + } + return undefined + } +} + +type PatternMatcher = (input: string) => string | undefined + +function createPatternMatcher (pattern: string): PatternMatcher { + const star = pattern.indexOf('*') + let obj: { [name: string]: PatternMatcher } = {} + if (star !== -1) { + const prefix = pattern.substring(0, star) + const start = prefix.length + if (star !== pattern.length - 1) { + const suffix = pattern.substring(star + 1) + const end = suffix.length + obj = { + [pattern] (test) { + if (test.startsWith(prefix) && test.endsWith(suffix)) { + return test.substring(start, test.length - end) + } + } + } + } else { + obj = { + [pattern] (test) { + if (test.startsWith(prefix)) { + return test.substring(start) + } + } + } + } + } else { + obj = { + [pattern] (test) { + if (test === pattern) { + return '' + } + } + } + } + return obj[pattern] +} + +function lookup (cause: string, pattern: string | null | undefined): ((input: string) => { cause: string, location: string | null }) | undefined { + if (pattern === undefined) { + return undefined + } + if (pattern === null) { + return () => ({ cause, location: null }) + } + const star = pattern.indexOf('*') + if (star !== -1) { + const prefix = pattern.substring(0, star) + if (star !== pattern.length - 1) { + const suffix = pattern.substring(star + 1) + return input => ({ cause, location: prefix + input + suffix }) + } + return input => ({ cause, location: prefix + input }) + } else { + return () => ({ cause, location: pattern }) + } +} + +type Resolver = (match: string) => Import + +function createResolver (type: Type, cause: string, part: Definition): Resolver | undefined { + if (part === null) { + return () => ({ cause, location: null }) + } + if (typeof part === 'string') { + return () => ({ cause, location: part }) + } + const directLookup = (type === 'module') + ? lookup(`${cause}.import`, part.import) + : lookup(`${cause}.node`, part.node) ?? lookup(`${cause}.require`, part.require) + return directLookup ?? lookup(`${cause}.default`, part.default) +} + +interface DefMatcher { + match: (input: string) => string | undefined + resolve: Resolver +} + +function longerMatchFirst ([a]: [string, Definition], [b]: [string, Definition]): 1 | -1 | 0 { + if (a.length > b.length) return -1 + if (a.length < b.length) return 1 + return 0 +} + +function createExportsMatcher (pkgPth: string, exports: { [key: string]: Definition }): Matcher { + const matchers: { + commonjs: DefMatcher[] + module: DefMatcher[] + } = { + commonjs: [], + module: [] + } + for (const [match, definition] of Object.entries(exports).sort(longerMatchFirst)) { + const cause = `.exports['${match}']` + const matcher = createPatternMatcher(match) + const cjs = createResolver('commonjs', cause, definition) + if (cjs !== undefined) { + matchers.commonjs.push({ + match: matcher, + resolve: cjs + }) + } + const mjs = createResolver('module', cause, definition) + if (mjs !== undefined) { + matchers.module.push({ + match: matcher, + resolve: mjs + }) + } + } + return (name, type) => { + const typeMatchers = matchers[type] + for (const matcher of typeMatchers) { + const lookup = matcher.match(name) + if (lookup === undefined) { + continue + } + const result = matcher.resolve(lookup) + const location = result.location + if (location !== undefined && location !== null) { + result.location = path.resolve(pkgPth, location) + } + return result + } + } +} + +export function createMatcher (pkgPth: string, pkg: Package): Matcher { + const exports = isDefinition(pkg.exports) ? { '.': pkg.exports } : pkg.exports + return (typeof exports === 'object' && exports !== null) + ? createExportsMatcher(pkgPth, exports) + : createLegacyMatcher(pkgPth, pkg) +} + +export function normalize (name: string | undefined | null): string { + if (name === undefined || name === null) { + name = '' + } + name = name.trim() + if (!name.startsWith('./')) { + name = `./${name}` + } + if (name.endsWith('/')) { + name = name.substring(0, name.length - 1) + } + return name +} + +const cache = new WeakMap() + +export function getMatcher (depPath: string, pkg: Package): Matcher { + let matcher = cache.get(pkg) + if (matcher === undefined) { + matcher = createMatcher(depPath, pkg) + cache.set(pkg, matcher) + } + return matcher +}