iobroker.javascript
Version:
Rules Engine for ioBroker
529 lines (489 loc) • 21 kB
JavaScript
;
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const { matchAll } = require('./tools');
/**
* Resolves all TypeScript lib files for the editor
* @param {string} targetLib The lib to target (e.g. es2017)
*/
function resolveTypescriptLibs(targetLib) {
const typescriptLibRoot = path.dirname(require.resolve(`typescript/lib/lib.d.ts`));
const ret = {};
const libReferenceRegex = /\/\/\/ <reference lib=["']([^"']+)["'] \/>/g;
const matchAllLibs = (string) => matchAll(libReferenceRegex, string).map(groups => groups[0]);
const libQueue = [targetLib];
while (libQueue.length > 0) {
const libName = libQueue.shift();
const filename = `lib.${libName}.d.ts`;
// Read the file and remember it in the return dictionary
const fileContent = fs.readFileSync(path.join(typescriptLibRoot, filename), 'utf8');
ret[filename] = fileContent;
// If this file references another lib file, we need to load that too
// A reference looks like this: /// <reference lib="es2015.core" />
// Find all libs we have not loaded yet
matchAllLibs(fileContent)
.filter(lib => !(`lib.${lib}.d.ts` in ret))
.forEach(lib => libQueue.push(lib));
}
return ret;
}
function normalizeDTSImport(filename) {
// An import is either...
// a normal import
if (filename.endsWith('.d.ts')) {
return filename;
}
// an extensionless import
if (fs.existsSync(`${filename}.d.ts`)) {
return `${filename}.d.ts`;
}
// or a directory import
return path.join(filename, 'index.d.ts');
}
/**
* Resolves the type declarations of a 3rd party package for the editor
* @param {string} pkg The package whose typings we're interested in
* @param {boolean} [wrapInDeclareModule=false] Whether the root file should be wrapped in `declare module "<pkg>" { ... }`
* @returns {Record<string, string> | undefined} The found declarations or undefined if none were found
*/
function resolveTypings(pkg, wrapInDeclareModule) {
let packageJsonPath;
let packageJson;
/** @type {string | undefined} */
let rootTypings;
let pkgIncludesTypings = true;
/**
* @param {string} path
*/
function tryToLoadPackage(path) {
try {
packageJsonPath = require.resolve(path);
packageJson = require(packageJsonPath);
rootTypings = typeof packageJson.types === 'string' ? packageJson.types
: typeof packageJson.typings === 'string' ? packageJson.typings
: undefined;
} catch { /* ignore */ }
}
// First, try to resolve the package itself in case it brings its own typings
tryToLoadPackage(`${pkg}/package.json`);
// If that didn't work, try again with the @types version of the package
if (!rootTypings) {
tryToLoadPackage(`@types/${pkg}/package.json`);
pkgIncludesTypings = false;
}
// TODO: If that didn't work, download @types/<packagename> and retry the previous step
// Nothing to do here since we found no packages
if (!rootTypings) return undefined;
const packageRoot = path.dirname(packageJsonPath);
const normalizeImportPath = filename => path.normalize(
`node_modules/${pkgIncludesTypings ? '' : '@types/'}${pkg}/${path.relative(packageRoot, filename)}`
).replace(/\\/g, '/');
/** @type {Record<string, string>} */
const ret = {};
// We need to look at `import/export ... from 'modulename'` and `/// <reference path='...' />`
const importDtsRegex = /^\s*(?:import|export) .+ from ["'](\.+\/[^"']+)["']/g;
const pathReferenceRegex = /\/\/\/ <reference path=["']([^"']+)["'] \/>/g;
const matchAllImports = string => [
...matchAll(importDtsRegex, string),
...matchAll(pathReferenceRegex, string)
].map(groups => groups[0]);
// the paths are relative to the package.json - we need an absolute path to read the files
rootTypings = path.join(packageRoot, rootTypings);
// some @types packages specify `index` as their typings file instead of `index.d.ts`
rootTypings = normalizeDTSImport(rootTypings);
// include package.json in typings, so TypeScript can look up the correct entry point
const relativePath = `node_modules/${pkgIncludesTypings ? '' : '@types/'}${pkg}/package.json`.replace(/\\/g, '/');
ret[relativePath] = JSON.stringify(packageJson);
// Used to test whether a .d.ts file already uses "declare module" or not
const declareModuleRegex = /^\s*declare module/gm;
// recursively load all typings
const definitionQueue = [rootTypings];
while (definitionQueue.length > 0) {
const filename = definitionQueue.shift();
const dirName = path.dirname(filename);
// Read the file and remember it in the return dictionary
let fileContent;
try {
fileContent = fs.readFileSync(filename, 'utf8');
} catch (e) {
// The typings are malformed
console.error(`Failed to load definitions for ${pkg}: ${e}`);
// Since we cannot use them, return undefined
return undefined;
}
// We need to store the filename relative to the base dir
const relativePath = normalizeImportPath(filename);
// If necessary, wrap the root typings (only those!)
ret[relativePath] = wrapInDeclareModule && filename === rootTypings && !declareModuleRegex.test(fileContent)
? `declare module "${pkg}" { ${fileContent} }`
: fileContent;
// If this file references another .d.ts file, we need to load that too
matchAllImports(fileContent)
// resolve the file relative to the current directory
.map(file => path.join(dirName, file))
// find out the correct path of the file we want to import
.map(normalizeDTSImport)
// Find all libs we have not loaded yet
.filter(file => !(normalizeImportPath(file) in ret))
.forEach(file => definitionQueue.push(file));
}
// Avoid returning empty declarations
if (Object.keys(ret).length === 0) {
return undefined;
}
return ret;
}
/**
* @param {import("typescript").Statement} s
* @param {boolean} isGlobal Whether this is a global script or a normal one
*/
function mustBeHoisted(s, isGlobal) {
return (
// Import/export statements must be moved to the top
ts.isImportDeclaration(s) ||
ts.isImportEqualsDeclaration(s) ||
ts.isExportDeclaration(s) ||
ts.isExportAssignment(s) ||
// as well as many declarations
ts.isTypeAliasDeclaration(s) ||
ts.isInterfaceDeclaration(s) ||
ts.isModuleDeclaration(s) ||
ts.isEnumDeclaration(s) ||
(isGlobal && (
// in global scripts we don't wrap classes and functions, so they can be accessed from non-global scripts
ts.isClassDeclaration(s) ||
ts.isFunctionDeclaration(s)
)) ||
// and declare ... / export ... statements
(s.modifiers &&
s.modifiers.some(
s => s.kind === ts.SyntaxKind.DeclareKeyword
|| s.kind === ts.SyntaxKind.ExportKeyword
))
);
}
/** @param {import("typescript").Statement} s */
function canBeExported(s) {
return (
// const, let, var
ts.isVariableStatement(s) ||
// type, interface, enum, class, function
ts.isTypeAliasDeclaration(s) ||
ts.isInterfaceDeclaration(s) ||
ts.isEnumDeclaration(s) ||
ts.isClassDeclaration(s) ||
ts.isFunctionDeclaration(s)
);
}
/**
* @param {import("typescript").Statement} s
*/
function addExportModifier(s) {
/** @type {import("typescript").Modifier[]} */
let modifiers;
// Add export modifiers
if (!s.modifiers) {
modifiers = [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)];
} else if (!s.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) {
modifiers = [...s.modifiers, ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)];
} else {
return s;
}
if (ts.isVariableStatement(s)) {
return ts.factory.updateVariableStatement(s, modifiers, s.declarationList);
}
if (ts.isTypeAliasDeclaration(s)) {
return ts.factory.updateTypeAliasDeclaration(s, modifiers, s.name, s.typeParameters, s.type);
}
if (ts.isInterfaceDeclaration(s)) {
return ts.factory.updateInterfaceDeclaration(s, modifiers, s.name, s.typeParameters, s.heritageClauses, s.members);
}
if (ts.isEnumDeclaration(s)) {
return ts.factory.updateEnumDeclaration(s, modifiers, s.name, s.members);
}
if (ts.isClassDeclaration(s)) {
return ts.factory.updateClassDeclaration(s, modifiers, s.name, s.typeParameters, s.heritageClauses, s.members);
}
if (ts.isFunctionDeclaration(s)) {
return ts.factory.updateFunctionDeclaration(s, modifiers, s.asteriskToken, s.name, s.typeParameters, s.parameters, s.type, s.body);
}
return s;
}
/**
* @param {import("typescript").Statement} s
*/
function removeDeclareModifier(s) {
/** @type {import("typescript").Modifier[] | undefined} */
let modifiers;
// Remove declare modifiers
if (s.modifiers) {
modifiers = s.modifiers.filter(m => m.kind !== ts.SyntaxKind.DeclareKeyword);
} else {
return s;
}
if (ts.isVariableStatement(s)) {
return ts.factory.updateVariableStatement(s, modifiers, s.declarationList);
}
if (ts.isTypeAliasDeclaration(s)) {
return ts.factory.updateTypeAliasDeclaration(s, modifiers, s.name, s.typeParameters, s.type);
}
if (ts.isInterfaceDeclaration(s)) {
return ts.factory.updateInterfaceDeclaration(s, modifiers, s.name, s.typeParameters, s.heritageClauses, s.members);
}
if (ts.isEnumDeclaration(s)) {
return ts.factory.updateEnumDeclaration(s, modifiers, s.name, s.members);
}
if (ts.isClassDeclaration(s)) {
return ts.factory.updateClassDeclaration(s, modifiers, s.name, s.typeParameters, s.heritageClauses, s.members);
}
if (ts.isFunctionDeclaration(s)) {
return ts.factory.updateFunctionDeclaration(s, modifiers, s.asteriskToken, s.name, s.typeParameters, s.parameters, s.type, s.body);
}
return s;
}
// taken from node_modules\@types\node\globals.d.ts
// the globally available things must be wrapped in `declare global` if the user wants to augment them
const NodeJSGlobals = [
'Array',
'ArrayBuffer',
'Boolean',
'Buffer',
'DataView',
'Date',
'Error',
'EvalError',
'Float32Array',
'Float64Array',
'Function',
'GLOBAL',
'Infinity',
'Int16Array',
'Int32Array',
'Int8Array',
'Intl',
'JSON',
'Map',
'Math',
'NaN',
'Number',
'Object',
'Promise',
'RangeError',
'ReferenceError',
'RegExp',
'Set',
'String',
'Symbol',
'SyntaxError',
'TypeError',
'URIError',
'Uint16Array',
'Uint32Array',
'Uint8Array',
'Uint8ClampedArray',
'WeakMap',
'WeakSet',
'clearImmediate',
'clearInterval',
'clearTimeout',
'console',
'decodeURI',
'decodeURIComponent',
'encodeURI',
'encodeURIComponent',
'escape',
'eval',
'global',
'isFinite',
'isNaN',
'parseFloat',
'parseInt',
'process',
'root',
'setImmediate',
'setInterval',
'setTimeout',
'queueMicrotask',
'undefined',
'unescape',
'gc',
'v8debug',
];
/** @param {import("typescript").Statement} s */
function isGlobalAugmentation(s) {
return (
(ts.isInterfaceDeclaration(s) || ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s))
&& s.name && NodeJSGlobals.includes(s.name.text)
);
}
/** @param {import("typescript").Statement[]} statements */
function wrapInDeclareGlobal(statements) {
return ts.factory.createModuleDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.DeclareKeyword)],
ts.factory.createIdentifier('global'),
ts.factory.createModuleBlock(statements),
ts.NodeFlags.GlobalAugmentation
);
}
/**
* Takes a TypeScript script and does the necessary transformations, so it can be compiled properly
* @param {string} source The original TypeScript source
* @param {boolean} isGlobal Whether the transformed script is a global script or not
* @returns {string}
*/
function transformScriptBeforeCompilation(source, isGlobal) {
/**
* @type {import("typescript").TransformerFactory<import("typescript").SourceFile>}
*/
// eslint-disable-next-line no-unused-vars
const transformer = _context => {
return sourceFile =>
ts.visitNode(sourceFile, node => {
if (ts.isSourceFile(node)) {
// Wrap all declarations that augment global interfaces in `declare global`
const augmentations = node.statements.filter((s) => isGlobalAugmentation(s));
const nonAugmentations = node.statements.filter((s) => !isGlobalAugmentation(s));
// If there is no top level await, don't move all the statements around
const hasTLA = node.statements.some(s => ts.isExpressionStatement(s) && s.expression.kind === ts.SyntaxKind.AwaitExpression);
// Move all statements to the top of the file that cannot appear in a function body
let hoistedStatements = hasTLA ? ts.factory.createNodeArray(nonAugmentations.filter((s) => mustBeHoisted(s, isGlobal))) : ts.factory.createNodeArray(nonAugmentations);
// The rest gets wrapped
const wrappedStatements = hasTLA ? nonAugmentations.filter(s => !mustBeHoisted(s, isGlobal)) : [];
// When transforming global scripts, we need to do a couple of things
if (isGlobal) {
// 1. We need to add an export modifier to everything on the top level that can be exported
hoistedStatements = ts.visitNodes(
hoistedStatements,
// @ts-expect-error s is definitely a statement
s => canBeExported(s) ? addExportModifier(s) : s,
);
// 3. We need to transform the generated declarations to use `declare global` (this will happen in transformGlobalDeclarations)
}
const needsEmptyExport =
// An empty export is needed when there is no import declaration
!node.statements.some(s => ts.isImportDeclaration(s) || ts.isImportEqualsDeclaration(s))
&& (
// And there is no statement in a global script which had an export modifier added
!(isGlobal && hoistedStatements.some(s => s.modifiers && s.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword)))
// Or if there is a `declare global` statement
|| !!augmentations
);
return ts.factory.updateSourceFile(node, [
// Put the hoisted statements at the top (or all of them if there's no top level await)
...hoistedStatements,
// Then add everything that augments the global scope
...(augmentations && augmentations.length ? [wrapInDeclareGlobal(augmentations)] : []),
...(hasTLA
? // If there is a top-level await, wrap all non-hoisted statements in (async () => { ... })();
[
ts.factory.createExpressionStatement(
ts.factory.createCallExpression(
ts.factory.createArrowFunction(
[
ts.factory.createModifier(
ts.SyntaxKind.AsyncKeyword
),
],
undefined,
[],
undefined,
undefined,
ts.factory.createBlock(wrappedStatements)
),
undefined,
undefined
)
),
]
: []),
...(needsEmptyExport
? [
// Put an empty export {}; at the bottom to force TypeScript to treat the script as a module
ts.factory.createExportDeclaration(
undefined,
undefined,
ts.factory.createNamedExports([]),
undefined,
undefined
),
]
: []),
]);
} else {
return node;
}
});
};
const sourceFile = ts.createSourceFile(
'index.ts',
source,
ts.ScriptTarget.ESNext,
/* setParentNodes */ true,
);
const result = ts.transform(sourceFile, [transformer]);
return ts.createPrinter().printNode(ts.EmitHint.Unspecified, result.transformed[0], sourceFile);
}
/**
* Takes the global declarations for a TypeScript and wraps export statements in `declare global`
* @param {string} decl The untransformed global declarations
* @returns {string}
*/
function transformGlobalDeclarations(decl) {
/**
* @type {import("typescript").TransformerFactory<import("typescript").SourceFile>}
*/
// eslint-disable-next-line no-unused-vars
const transformer = context => {
return sourceFile =>
ts.visitNode(sourceFile, (node) => {
if (ts.isSourceFile(node)) {
// All non-export-statements stay at the root level, the rest is wrapped in `declare global`
const exportStatements = node.statements.filter(s => s.modifiers && s.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword));
const otherStatements = node.statements.filter(s => !exportStatements.includes(s));
const hasExportStatements = exportStatements.length > 0;
const hasImport = otherStatements.some(s => ts.isImportDeclaration(s) || ts.isImportEqualsDeclaration(s));
return ts.factory.updateSourceFile(node, [
...otherStatements,
...(hasExportStatements ? [wrapInDeclareGlobal(exportStatements.map((s) => removeDeclareModifier(s)))] : []),
...(hasImport
? [] // If there is an import, the script is already treated as a module
: [
// Otherwise put an empty export {}; at the bottom to force TypeScript to treat the script as a module
ts.factory.createExportDeclaration(
undefined,
undefined,
ts.factory.createNamedExports([]),
undefined,
undefined,
),
]),
]);
} else {
return node;
}
});
};
const sourceFile = ts.createSourceFile(
'index.d.ts',
decl,
ts.ScriptTarget.ESNext,
/* setParentNodes */ true,
);
const result = ts.transform(sourceFile, [transformer]);
return ts.createPrinter().printNode(ts.EmitHint.Unspecified, result.transformed[0], sourceFile);
}
/**
* Translates a script ID to a filename for the compiler
* @param {string} scriptID The ID of the script
*/
function scriptIdToTSFilename(scriptID) {
return `${scriptID.replace(/^script.js./, '').replace(/\./g, '/')}.ts`;
}
module.exports = {
scriptIdToTSFilename,
resolveTypescriptLibs,
resolveTypings,
transformScriptBeforeCompilation,
transformGlobalDeclarations,
};