Skip to content

Commit

Permalink
feat: import() in CommonJS & resolve virtual file extensions (#43)
Browse files Browse the repository at this point in the history
* chore: add tests to reproduce CJS dynamic-import issue;

- Related #27

* feat: handle import() in CJS & backmap TS->JS resolving;

- attempt to load ".ts", ".jsx" or ".tsx" file when `import` to ".js" does not exist
- attempt to load ".mts" file when `import` to ".mjs" does not exist
- attempt to load ".cts" file when `import` to ".cjs" does not exist
- attempt to load ".tsx" file when `import` to ".jsx" does not exist

* chore: wrap ESM test file in async iife

* derp~!
  • Loading branch information
lukeed authored Nov 28, 2022
1 parent 2d406ef commit 83fff52
Show file tree
Hide file tree
Showing 9 changed files with 303 additions and 137 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"devDependencies": {
"@types/node": "16.11.6",
"@types/react": "17.0.33",
"typescript": "4.4.4"
"typescript": "4.9.3"
},
"keywords": [
"esm",
Expand Down
64 changes: 42 additions & 22 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Resolve = (
fallback: Resolve
) => Promisable<{
url: string;
shortCircuit: boolean;
format?: Format;
}>;

Expand All @@ -45,6 +46,7 @@ type Load = (
fallback: Load
) => Promisable<{
format: Format;
shortCircuit: boolean;
source: Source;
}>;

Expand All @@ -56,7 +58,7 @@ async function toConfig(): Promise<Config> {

const EXTN = /\.\w+(?=\?|$)/;
const isTS = /\.[mc]?tsx?(?=\?|$)/;
const isJS = /\.([mc])?js$/;

async function toOptions(uri: string): Promise<Options|void> {
config = config || await toConfig();
let [extn] = EXTN.exec(uri) || [];
Expand All @@ -68,45 +70,63 @@ function check(fileurl: string): string | void {
if (existsSync(tmp)) return fileurl;
}

/**
* extension aliases; runs after checking for extn on disk
* @example `import('./foo.mjs')` but only `foo.mts` exists
*/
const MAPs: Record<Extension, Extension[]> = {
'.js': ['.ts', '.tsx', '.jsx'],
'.jsx': ['.tsx'],
'.mjs': ['.mts'],
'.cjs': ['.cts'],
};

const root = new URL('file:///' + process.cwd() + '/');
export const resolve: Resolve = async function (ident, context, fallback) {
// ignore "prefix:" and non-relative identifiers
if (/^\w+\:?/.test(ident)) return fallback(ident, context, fallback);

let match: RegExpExecArray | null;
let idx: number, ext: Extension, path: string | void;
let output = new URL(ident, context.parentURL || root);
let target = new URL(ident, context.parentURL || root);
let ext: Extension, path: string | void, arr: Extension[];
let match: RegExpExecArray | null, i=0, base: string;

// source ident includes extension
if (match = EXTN.exec(output.href)) {
if (match = EXTN.exec(target.href)) {
ext = match[0] as Extension;
if (!context.parentURL || isTS.test(ext)) {
return { url: output.href, shortCircuit: true };
return { url: target.href, shortCircuit: true };
}
// source ident exists
path = check(output.href);
if (path) return { url: path, shortCircuit: true };
// parent importer is a ts file
// source ident is js & NOT exists
if (isJS.test(ext) && isTS.test(context.parentURL)) {
// reconstruct ".js" -> ".ts" source file
path = output.href.substring(0, idx = match.index);
if (path = check(path + ext.replace('js', 'ts'))) {
idx += ext.length;
if (idx > output.href.length) {
path += output.href.substring(idx);

// target ident exists
if (path = check(target.href)) {
return { url: path, shortCircuit: true };
}

// target is virtual alias
if (arr = MAPs[ext]) {
base = target.href.substring(0, match.index);
for (; i < arr.length; i++) {
if (path = check(base + arr[i])) {
i = match.index + ext.length;
return {
shortCircuit: true,
url: i > target.href.length
// handle target `?args` trailer
? base + target.href.substring(i)
: path
};
}
return { url: path, shortCircuit: true };
}
// return original, let it error
return fallback(ident, context, fallback);
}

// return original behavior, let it error
return fallback(ident, context, fallback);
}

config = config || await toConfig();

for (ext in config) {
path = check(output.href + ext);
path = check(target.href + ext);
if (path) return { url: path, shortCircuit: true };
}

Expand Down
38 changes: 26 additions & 12 deletions src/require.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const { extname } = require('path');
const { readFileSync } = require('fs');
const { extname } = require('path');
const tsm = require('./utils');

import type { Config, Options } from 'tsm/config';
import type { Config, Extension, Options } from 'tsm/config';
type TSM = typeof import('./utils.d');

type Module = NodeJS.Module & {
Expand Down Expand Up @@ -31,21 +31,31 @@ const tsrequire = 'var $$req=require("module").createRequire(__filename);require
if (/^\w+\:?/.test(ident)) return $$req(ident);

// exit early if no extension provided
let match = /\.([mc])?js(?=\?|$)/.exec(ident);
let match = /\.([mc])?[tj]sx?(?=\?|$)/.exec(ident);
if (match == null) return $$req(ident);

let base = $url.pathToFileURL(__filename);
let file = $url.fileURLToPath(new $url.URL(ident, base));
if (existsSync(file)) return $$req(ident);

// ?js -> ?ts file
file = file.replace(
new RegExp(match[0] + '$'),
match[0].replace('js', 'ts')
);
let extn = match[0] as Extension;
let rgx = new RegExp(extn + '$');

// [cm]?jsx? -> [cm]?tsx?
let tmp = file.replace(rgx, extn.replace('js', 'ts'));
if (existsSync(tmp)) return $$req(tmp);

// return the new "[mc]ts" file, or let error
return existsSync(file) ? $$req(file) : $$req(ident);
// look for ".[tj]sx" if ".js" given & still here
if (extn === '.js') {
tmp = file.replace(rgx, '.tsx');
if (existsSync(tmp)) return $$req(tmp);

tmp = file.replace(rgx, '.jsx');
if (existsSync(tmp)) return $$req(tmp);
}

// let it error
return $$req(ident);
}
})
} + ')();';
Expand All @@ -56,13 +66,17 @@ function transform(source: string, options: Options): string {
}

function loader(Module: Module, sourcefile: string) {
let extn = extname(sourcefile);
let extn = extname(sourcefile) as Extension;

let options = config[extn] || {};
let pitch = Module._compile!.bind(Module);
options.sourcefile = sourcefile;

if (/\.[mc]?tsx?$/.test(extn)) {
if (/\.[mc]?[tj]sx?$/.test(extn)) {
options.banner = tsrequire + (options.banner || '');
// https://github.com/lukeed/tsm/issues/27
options.supported = options.supported || {};
options.supported['dynamic-import'] = false;
}

if (config[extn] != null) {
Expand Down
93 changes: 61 additions & 32 deletions test/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,69 @@ import * as assert from 'assert';

// NOTE: doesn't actually exist yet
import * as js from '../fixtures/math.js';
// @ts-ignore - cannot find types
import * as mjs from '../fixtures/utils.mjs';
// @ts-ignore - cannot find types
import * as cjs from '../fixtures/utils.cjs';
// @ts-ignore - cannot find types
import * as esm from '../fixtures/module/index.js';

import * as esm1 from '../fixtures/module/index.js';
import * as esm2 from '../fixtures/module/index.mjs';

// NOTE: avoid need for syntheticDefault + analysis
import * as data from '../fixtures/data.json';
assert.equal(typeof data, 'object');

// @ts-ignore - generally doesn't exist
assert.equal(typeof data.default, 'string');

// NOTE: raw JS missing
assert.equal(typeof js, 'object', 'JS :: typeof');
assert.equal(typeof js.sum, 'function', 'JS :: typeof :: sum');
assert.equal(typeof js.div, 'function', 'JS :: typeof :: div');
assert.equal(typeof js.mul, 'function', 'JS :: typeof :: mul');
assert.equal(js.foobar, 3, 'JS :: value :: foobar');

// NOTE: raw MJS missing
assert.equal(typeof mjs, 'object', 'MJS :: typeof');
assert.equal(typeof mjs.capitalize, 'function', 'MJS :: typeof :: capitalize');
assert.equal(mjs.capitalize('hello'), 'Hello', 'MJS :: value :: capitalize');

// NOTE: raw CJS missing
assert.equal(typeof cjs, 'object', 'CJS :: typeof');
assert.equal(typeof cjs.dashify, 'function', 'CJS :: typeof :: dashify');
assert.equal(cjs.dashify('FooBar'), 'foo-bar', 'CJS :: value :: dashify');

// Checking ".js" with ESM content (type: module)
assert.equal(typeof esm, 'object', 'ESM.js :: typeof');
assert.equal(typeof esm.hello, 'function', 'ESM.js :: typeof :: hello');
assert.equal(esm.hello('you'), 'hello, you', 'ESM.js :: value :: hello');

console.log('DONE~!');

// NOTE: for CJS test runner
(async function () {
assert.equal(typeof data, 'object');

// @ts-ignore - generally doesn't exist
assert.equal(typeof data.default, 'string');

// NOTE: raw JS missing
assert.equal(typeof js, 'object', 'JS :: typeof');
assert.equal(typeof js.sum, 'function', 'JS :: typeof :: sum');
assert.equal(typeof js.div, 'function', 'JS :: typeof :: div');
assert.equal(typeof js.mul, 'function', 'JS :: typeof :: mul');
assert.equal(js.foobar, 3, 'JS :: value :: foobar');

// DYANMIC IMPORTS via TS file
assert.equal(typeof js.dynamic, 'object', 'JS :: typeof :: dynamic');
assert.equal(await js.dynamic.cjs(), 'foo-bar', 'JS :: dynamic :: import(cjs)');
assert.equal(await js.dynamic.cts(), 'foo-bar', 'JS :: dynamic :: import(cts)');
assert.equal(await js.dynamic.mjs(), 'Hello', 'JS :: dynamic :: import(mjs)');
assert.equal(await js.dynamic.mts(), 'Hello', 'JS :: dynamic :: import(mts)');

// NOTE: raw MJS missing
assert.equal(typeof mjs, 'object', 'MJS :: typeof');
assert.equal(typeof mjs.capitalize, 'function', 'MJS :: typeof :: capitalize');
assert.equal(mjs.capitalize('hello'), 'Hello', 'MJS :: value :: capitalize');

// NOTE: raw CJS missing
assert.equal(typeof cjs, 'object', 'CJS :: typeof');
assert.equal(typeof cjs.dashify, 'function', 'CJS :: typeof :: dashify');
assert.equal(cjs.dashify('FooBar'), 'foo-bar', 'CJS :: value :: dashify');

// Checking ".js" with ESM content (type: module)
assert.equal(typeof esm1, 'object', 'ESM.js :: typeof');
assert.equal(typeof esm1.hello, 'function', 'ESM.js :: typeof :: hello');
assert.equal(esm1.hello('you'), 'hello, you', 'ESM.js :: value :: hello');

// DYANMIC IMPORTS via JS file
assert.equal(typeof esm1.dynamic, 'object', 'ESM.js :: typeof :: dynamic');
assert.equal(await esm1.dynamic.cjs(), 'foo-bar', 'ESM.js :: dynamic :: import(cjs)');
assert.equal(await esm1.dynamic.cts(), 'foo-bar', 'ESM.js :: dynamic :: import(cts)');
assert.equal(await esm1.dynamic.mjs(), 'Hello', 'ESM.js :: dynamic :: import(mjs)');
assert.equal(await esm1.dynamic.mts(), 'Hello', 'ESM.js :: dynamic :: import(mts)');

// Checking ".mjs" with ESM content
assert.equal(typeof esm2, 'object', 'ESM.mjs :: typeof');
assert.equal(typeof esm2.hello, 'function', 'ESM.mjs :: typeof :: hello');
assert.equal(esm2.hello('you'), 'hello, you', 'ESM.mjs :: value :: hello');

// DYANMIC IMPORTS via MJS file
assert.equal(typeof esm2.dynamic, 'object', 'ESM.mjs :: typeof :: dynamic');
assert.equal(await esm2.dynamic.cjs(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cjs)');
assert.equal(await esm2.dynamic.cts(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cts)');
assert.equal(await esm2.dynamic.mjs(), 'Hello', 'ESM.mjs :: dynamic :: import(mjs)');
assert.equal(await esm2.dynamic.mts(), 'Hello', 'ESM.mjs :: dynamic :: import(mts)');

console.log('DONE~!');
})();
23 changes: 23 additions & 0 deletions test/fixtures/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,26 @@ export const div = (a: number, b: number) => a / b;
export const mul = (a: number, b: number) => a * b;

export const foobar = sum(1, 2);

export const dynamic = {
async cjs() {
// @ts-ignore – tsc cant find type defs
let m = await import('./utils.cjs');
return m.dashify('FooBar');
},
async cts() {
// @ts-ignore – tsc doesnt like
let m = await import('./utils.cts');
return m.dashify('FooBar');
},
async mjs() {
// @ts-ignore – tsc cant find type defs
let m = await import('./utils.mjs');
return m.capitalize('hello');
},
async mts() {
// @ts-ignore – tsc doesnt like
let m = await import('./utils.mts');
return m.capitalize('hello');
},
}
21 changes: 21 additions & 0 deletions test/fixtures/module/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,24 @@
export function hello(name) {
return `hello, ${name}`;
}

export const dynamic = {
async cjs() {
let m = await import('../utils.cjs');
return m.dashify('FooBar');
},
async cts() {
// @ts-ignore – tsc doesnt like
let m = await import('../utils.cts');
return m.dashify('FooBar');
},
async mjs() {
let m = await import('../utils.mjs');
return m.capitalize('hello');
},
async mts() {
// @ts-ignore – tsc doesnt like
let m = await import('../utils.mts');
return m.capitalize('hello');
},
}
21 changes: 21 additions & 0 deletions test/fixtures/module/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,24 @@
export function hello(name) {
return `hello, ${name}`;
}

export const dynamic = {
async cjs() {
let m = await import('../utils.cjs');
return m.dashify('FooBar');
},
async cts() {
// @ts-ignore – tsc doesnt like
let m = await import('../utils.cts');
return m.dashify('FooBar');
},
async mjs() {
let m = await import('../utils.mjs');
return m.capitalize('hello');
},
async mts() {
// @ts-ignore – tsc doesnt like
let m = await import('../utils.mts');
return m.capitalize('hello');
},
}
Loading

0 comments on commit 83fff52

Please sign in to comment.