From 13d1ad46f0d4e3e73c86cdd5ed4427d141d6af71 Mon Sep 17 00:00:00 2001 From: Alex Khizhnyi Date: Thu, 13 Aug 2026 23:00:02 +0300 Subject: [PATCH] Make typescript an optional peer dependency and load TS/Vue detectives lazily --- index.js | 80 +++++++++++++++++++-- package-lock.json | 12 +++- package.json | 11 ++- test/lazy-detectives.test.js | 134 +++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 test/lazy-detectives.test.js diff --git a/index.js b/index.js index 5d81ecc..e2bd30d 100644 --- a/index.js +++ b/index.js @@ -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'; @@ -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 & { * type?: string, @@ -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: diff --git a/package-lock.json b/package-lock.json index 131b1a4..91f6abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,7 @@ "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" }, "bin": { "precinct": "bin/cli.js" @@ -36,6 +35,14 @@ }, "engines": { "node": ">=20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "typescript": "^5.4.4 || ^6.0.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -7962,6 +7969,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index c0ce54b..1eb757c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/lazy-detectives.test.js b/test/lazy-detectives.test.js new file mode 100644 index 0000000..6655c93 --- /dev/null +++ b/test/lazy-detectives.test.js @@ -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('', { type: 'vue' }); + console.log('no-error'); + } catch (error) { + console.log(error.message); + } + `, { hideTypeScript: true }); + + expect(output).toContain('requires the "typescript" peer dependency'); + }); +});