Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 74 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from 'node:fs';
import { builtinModules } from 'node:module';
import { builtinModules, createRequire } from 'node:module';
import path from 'node:path';
import { debuglog } from 'node:util';
import getModuleType from 'module-definition';
Expand All @@ -12,11 +12,79 @@ import detectivePostcss from 'detective-postcss';
import detectiveSass from 'detective-sass';
import detectiveScss from 'detective-scss';
import detectiveStylus from 'detective-stylus';
import detectiveTypeScript from 'detective-typescript';
import detectiveVue from 'detective-vue2';

const require = createRequire(import.meta.url);

const debug = debuglog('precinct');

// The TypeScript and Vue detectives are loaded on demand: they pull in the TypeScript
// compiler and the Vue SFC compiler, which the majority of consumers never need. Both
// are required synchronously via require(ESM), supported on every Node version this
// package supports, so the public API stays synchronous.
let detectiveTypeScript;
let detectiveVue;

// Tagged so callers can tell an unusable peer dependency apart from a file that failed to parse
const typeScriptUnavailableCode = 'ERR_TYPESCRIPT_UNAVAILABLE';

/**
* Loads a detective that needs the optional `typescript` peer dependency. The load is
* attempted rather than guarded by a version check, so whatever the peer dependency
* happens to be, the failure it produces deep inside the detective's own dependencies
* is rewritten into something the consumer can act on.
*
* @param {string} name - Detective package to load
* @return {any}
*/
function loadTypeScriptDetective(name) {
debug('loading %s on demand', name);

try {
return require(name).default;
} catch(error) {
debug('could not load %s: %s', name, error.message);
const failure = new Error(explainLoadFailure(name, error), { cause: error });
throw Object.assign(failure, { code: typeScriptUnavailableCode });
}
}

/**
* @param {string} name - Detective package that failed to load
* @param {Error} error
* @return {string}
*/
function explainLoadFailure(name, error) {
const version = installedTypeScriptVersion();

if (!version) {
return `${name} requires the "typescript" peer dependency, which is not installed. ` +
'Run `npm install typescript` to analyze TypeScript and Vue files.';
}

return `${name} could not be loaded with typescript@${version} installed: ${error.message}`;
}

/**
* @return {string | undefined}
*/
function installedTypeScriptVersion() {
try {
return require('typescript').version;
} catch {
return undefined;
}
}

function loadDetectiveTypeScript() {
detectiveTypeScript ??= loadTypeScriptDetective('detective-typescript');
return detectiveTypeScript;
}

function loadDetectiveVue() {
detectiveVue ??= loadTypeScriptDetective('detective-vue2');
return detectiveVue;
}

/**
* @typedef {Record<string, unknown> & {
* type?: string,
Expand Down Expand Up @@ -197,15 +265,15 @@ function getDetective(type, options) {
}

case 'ts': {
return detectiveTypeScript;
return loadDetectiveTypeScript();
}

case 'tsx': {
return detectiveTypeScript.tsx;
return loadDetectiveTypeScript().tsx;
}

case 'vue': {
return detectiveVue;
return loadDetectiveVue();
}

default:
Expand Down
12 changes: 10 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,15 @@
"detective-vue2": "^3.0.1",
"module-definition": "^7.0.0",
"node-source-walk": "^8.0.0",
"postcss": "^8.5.25",
"typescript": "^6.0.3"
"postcss": "^8.5.25"
},
"peerDependencies": {
"typescript": "^5.4.4 || ^6.0.2"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"devDependencies": {
"@babel/parser": "^7.29.8",
Expand Down
134 changes: 134 additions & 0 deletions test/lazy-detectives.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.join(__dirname, '..');

/**
* Runs a snippet in a child process so module loading starts from a clean slate,
* optionally making `typescript` unresolvable or a detective unloadable.
*
* @param {string} source
* @param {{ hideTypeScript?: boolean, breakDetective?: string }} [options]
* @returns {string}
*/
function runInChildProcess(source, options = {}) {
let preamble = '';

if (options.hideTypeScript) {
preamble = `import Module from 'node:module';
const originalResolve = Module._resolveFilename;
Module._resolveFilename = function(request, ...rest) {
if (request === 'typescript') {
throw Object.assign(new Error("Cannot find module 'typescript'"), { code: 'MODULE_NOT_FOUND' });
}

return originalResolve.call(this, request, ...rest);
};`;
} else if (options.breakDetective) {
preamble = `import Module from 'node:module';
const originalLoad = Module._load;
Module._load = function(request, ...rest) {
if (request === '${options.breakDetective}') {
throw new TypeError('ts.createSourceFile is not a function');
}

return originalLoad.call(this, request, ...rest);
};`;
}

return execFileSync(process.execPath, ['--input-type=module', '--eval', preamble + source], {
cwd: rootDir,
encoding: 'utf8'
}).trim();
}

describe('lazily loaded detectives', () => {
it('does not load the typescript compiler when parsing JavaScript', () => {
const output = runInChildProcess(`
import Module from 'node:module';
const loaded = [];
const originalLoad = Module._load;
Module._load = function(request, ...rest) {
if (request === 'typescript') loaded.push(request);
return originalLoad.call(this, request, ...rest);
};

const { default: precinct } = await import('./index.js');
precinct('const foo = require("./bar");');
console.log(loaded.length === 0 ? 'not-loaded' : 'loaded');
`);

expect(output).toBe('not-loaded');
});

it('loads the typescript detective on demand for ts files', () => {
const output = runInChildProcess(`
import Module from 'node:module';
let loaded = false;
const originalLoad = Module._load;
Module._load = function(request, ...rest) {
if (request === 'typescript') loaded = true;
return originalLoad.call(this, request, ...rest);
};

const { default: precinct } = await import('./index.js');
const dependencies = precinct('import foo from "./bar";', { type: 'ts' });
console.log(JSON.stringify({ loaded, dependencies }));
`);

expect(JSON.parse(output)).toStrictEqual({ loaded: true, dependencies: ['./bar'] });
});

it('throws an actionable error for ts files when typescript is not installed', () => {
const output = runInChildProcess(`
const { default: precinct } = await import('./index.js');

try {
precinct('import foo from "./bar";', { type: 'ts' });
console.log('no-error');
} catch (error) {
console.log(error.message);
}
`, { hideTypeScript: true });

expect(output).toContain('requires the "typescript" peer dependency');
});

it('rewrites a detective that cannot load with the installed typescript', () => {
const output = runInChildProcess(`
const { default: precinct } = await import('./index.js');

try {
precinct('import foo from "./bar";', { type: 'ts' });
console.log('no-error');
} catch (error) {
console.log(JSON.stringify({ code: error.code, message: error.message, cause: error.cause?.message }));
}
`, { breakDetective: 'detective-typescript' });

const error = JSON.parse(output);
expect(error.code).toBe('ERR_TYPESCRIPT_UNAVAILABLE');
expect(error.message).toMatch(/could not be loaded with typescript@\d+\.\d+/v);
expect(error.message).toContain('ts.createSourceFile is not a function');
expect(error.cause).toBe('ts.createSourceFile is not a function');
});

it('throws an actionable error for vue files when typescript is not installed', () => {
const output = runInChildProcess(`
const { default: precinct } = await import('./index.js');

try {
precinct('<template></template>', { type: 'vue' });
console.log('no-error');
} catch (error) {
console.log(error.message);
}
`, { hideTypeScript: true });

expect(output).toContain('requires the "typescript" peer dependency');
});
});