telefunc
Version:
Remote functions. Instead of API.
305 lines (304 loc) • 12.6 kB
JavaScript
export { generateShield };
export { logResult };
// For ./generateShield.spec.ts
export { testGenerateShield };
import { Project, VariableDeclarationKind, getCompilerOptionsFromTsConfig } from 'ts-morph';
import { assert, assertUsage, assertWarning, assertModuleScope, objectAssign, unique, assertPosixPath, getRandomId, } from '../../utils.js';
import { getExportList } from '../getExportList.js';
import fs from 'node:fs';
import path from 'node:path';
import pc from '@brillout/picocolors';
import { fileURLToPath } from 'node:url';
const __dirname_ = path.dirname(fileURLToPath(import.meta.url));
assertModuleScope('generateShield/generateShield.ts');
const generatedShields = [];
let resultAlreadyLogged = false;
const projects = {};
function generateShield(telefuncFileCode, telefuncFilePath, appRootDir, exportList) {
const { project, shieldGenSource } = getProject(telefuncFilePath, telefuncFileCode, appRootDir);
const shieldCode = generate({
project,
shieldGenSource,
telefuncFilePath,
exportList,
});
return shieldCode;
}
function getProject(telefuncFilePath, telefuncFileCode, appRootDir, isTest) {
const tsConfigFilePath = isTest ? null : findTsConfig(telefuncFilePath, appRootDir);
const key = tsConfigFilePath !== null && tsConfigFilePath !== void 0 ? tsConfigFilePath : '__no_tsconfig';
const typeToShieldFilePath = path.join(getFilesystemRoot(), '__telefunc_typeToShield.ts');
if (!projects[key]) {
let project;
if (!tsConfigFilePath) {
project = projects[key] = new Project({
compilerOptions: {
strict: true,
},
});
}
else {
project = projects[key] = new Project({
tsConfigFilePath,
// Add all project files, which is needed for picking up the Telefunc.Context value
// - What `Telefunc.Context` is, is explained at https://telefunc.com/getContext#typescript
skipAddingFilesFromTsConfig: false,
});
const compilerOptions = getCompilerOptionsFromTsConfig(tsConfigFilePath);
assertUsage(compilerOptions.options.strict === true, `Set \`compilerOptions.strict\` to \`true\` in ${tsConfigFilePath} (needed for shield() generation)`);
}
// This source file is used for evaluating the template literal types' values
project.createSourceFile(typeToShieldFilePath, getTypeToShieldSrc());
}
const project = projects[key];
objectAssign(project, { tsConfigFilePath });
if (!tsConfigFilePath) {
assert(!project.getSourceFile(telefuncFilePath));
project.createSourceFile(telefuncFilePath, telefuncFileCode,
// We need `overwrite` because `telefuncFilePath` already exists on the filesystem
{ overwrite: true });
}
const shieldGenFilePath = path.join(path.dirname(telefuncFilePath), `__telefunc_shieldGen_${path.basename(telefuncFilePath)}`);
const shieldGenSource = project.createSourceFile(shieldGenFilePath, undefined, { overwrite: true });
shieldGenSource.addImportDeclaration({
moduleSpecifier: getImportPath(shieldGenFilePath, typeToShieldFilePath),
namedImports: ['ShieldArrStr'],
});
const telefuncFileSource = project.getSourceFile(telefuncFilePath);
assertTelefuncFilesSource(telefuncFileSource, { project, telefuncFilePath, tsConfigFilePath, appRootDir });
// The code written in the file at `telefuncFilePath` isn't equal `telefuncFileCode` because of transforms
telefuncFileSource.replaceWithText(telefuncFileCode);
return { project, shieldGenSource };
}
function generate({ project, shieldGenSource, telefuncFilePath, exportList, }) {
shieldGenSource.addImportDeclaration({
moduleSpecifier: getTelefuncFileImportPath(telefuncFilePath),
namedImports: exportList.map((e) => e.exportName),
});
// Assign the template literal type to a string, then diagnostics are used to get the value of the template literal type.
for (const e of exportList) {
shieldGenSource.addTypeAlias({
name: getShieldName(e.exportName),
type: `ShieldArrStr<Parameters<typeof ${e.exportName}>>`,
});
}
const shieldAlias = '__telefunc_shield'; // alias for shield
const shieldFile = project.createSourceFile(getShieldFilePath(telefuncFilePath), undefined, { overwrite: true });
shieldFile.addImportDeclaration({
moduleSpecifier: 'telefunc',
namedImports: [
{
name: 'shield',
alias: shieldAlias,
},
],
});
shieldFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: '__telefunc_t',
initializer: `${shieldAlias}.type`,
},
],
});
// Add the dependent source files to the project
project.resolveSourceFileDependencies();
// We need `compilerOptions.strict` to avoid `TS2589: Type instantiation is excessively deep and possibly infinite.`
assert(project.compilerOptions.get().strict === true);
for (const exportedFunction of exportList) {
const typeAliasName = getShieldName(exportedFunction.exportName);
const typeAlias = shieldGenSource.getTypeAlias(typeAliasName);
assert(typeAlias, `Failed to get type alias \`${typeAliasName}\`.`);
const shieldStrType = typeAlias.getType();
const shieldStr = shieldStrType.getLiteralValue();
assert(shieldStr === undefined || typeof shieldStr === 'string');
const failed = shieldStr === undefined;
generatedShields.push({
project,
telefuncFilePath,
telefunctionName: exportedFunction.exportName,
failed,
});
if (failed)
continue;
shieldFile.addStatements(`${shieldAlias}(${exportedFunction.localName}, ${shieldStr}, { __autoGenerated: true })`);
}
return shieldFile.getText();
}
async function testGenerateShield(telefuncFileCode) {
const telefuncFilePath = `virtual-${getRandomId()}.telefunc.ts`;
const { project, shieldGenSource } = getProject(telefuncFilePath, telefuncFileCode, '/fake-user-root-dir/', true);
objectAssign(project, { tsConfigFilePath: null });
const exportList = await getExportList(telefuncFileCode);
return generate({
project,
shieldGenSource,
telefuncFilePath,
exportList,
});
}
function getShieldFilePath(p) {
const parts = p.split('.');
parts.splice(-1, 0, 'generated-shield');
return parts.join('.');
}
function getImportPath(importer, importedFile) {
let importPath = path.relative(path.dirname(importer), importedFile);
importPath = toImport(importPath);
return importPath;
}
function getTelefuncFileImportPath(telefuncFilePath) {
let importPath = path.basename(telefuncFilePath);
importPath = toImport(importPath);
return importPath;
}
function toImport(importPath) {
assert(importPath.endsWith('.ts'));
importPath = importPath.slice(0, -1 * '.ts'.length);
if (process.platform === 'win32') {
importPath = importPath.split('\\').join('/');
}
return `./${importPath}`;
}
function logResult(appRootDir, logSuccessPrefix, logIntro) {
// `generatedShields` is empty for JavaScript users
if (generatedShields.length === 0)
return;
if (resultAlreadyLogged) {
assert(generatedShields.length === 0);
return;
}
if (logIntro)
console.log(logIntro);
printSuccesses(appRootDir, logSuccessPrefix);
printFailures(appRootDir);
resultAlreadyLogged = true;
generatedShields.length = 0;
}
function printFailures(appRootDir) {
const failures = generatedShields.filter((s) => s.failed);
const projects = unique(failures.map((f) => f.project));
let hasTypeScriptErrors = false;
projects.forEach((project) => {
const diagnostics = project.getPreEmitDiagnostics();
if (diagnostics.length > 0) {
hasTypeScriptErrors = true;
if (project.tsConfigFilePath) {
console.log(`TypeScript project ${pc.bold(project.tsConfigFilePath)} errors:`);
}
else {
console.log(`TypeScript errors:`);
}
console.log(project.formatDiagnosticsWithColorAndContext(diagnostics));
}
});
assertWarning(failures.length === 0, [
'Failed to generate shield() for telefunction',
failures.length === 1 ? '' : 's',
' ',
formatGeneratedShields(failures, appRootDir),
'.',
!hasTypeScriptErrors
? ''
: ' TypeScript errors (printed above) can be problematic for shield() generation. Fix your TypeScript errors and try again.',
' See https://telefunc.com/shield#typescript-automatic for more information.',
].join(''), { onlyOnce: true });
}
function printSuccesses(appRootDir, logSuccessPrefix) {
const successes = generatedShields.filter((s) => !s.failed);
if (successes.length > 0) {
console.log([
logSuccessPrefix,
`shield() generated for the telefunction${generatedShields.length === 1 ? '' : 's'}`,
formatGeneratedShields(successes, appRootDir),
].join(' '));
}
}
function formatGeneratedShields(generatedShields, appRootDir) {
return formatList(generatedShields.map(({ telefunctionName, telefuncFilePath }) => {
telefuncFilePath = path.relative(appRootDir, telefuncFilePath);
return `${telefunctionName}() (${telefuncFilePath})`;
}));
}
function formatList(list) {
return new Intl.ListFormat('en').format(list);
}
function getShieldName(telefunctionName) {
return `${telefunctionName}Shield`;
}
let typeToShieldSrc;
function getTypeToShieldSrc() {
if (!typeToShieldSrc) {
try {
typeToShieldSrc = fs.readFileSync(`${__dirname_}/typeToShield.d.ts`).toString();
}
catch {
typeToShieldSrc = fs.readFileSync(`${__dirname_}/typeToShield.ts`).toString();
}
}
assert(typeToShieldSrc);
assert(typeToShieldSrc.includes('SimpleType'));
return typeToShieldSrc;
}
function findTsConfig(telefuncFilePath, appRootDir) {
assert(fs.existsSync(telefuncFilePath));
assertPosixPath(telefuncFilePath);
assertPosixPath(appRootDir);
assert(telefuncFilePath.startsWith(appRootDir));
let curr = telefuncFilePath;
do {
const dir = path.dirname(curr);
if (dir === curr) {
return null;
}
if (!dir.startsWith(appRootDir)) {
return null;
}
const tsConfigFilePath = path.join(dir, 'tsconfig.json');
if (fs.existsSync(tsConfigFilePath)) {
return tsConfigFilePath;
}
curr = dir;
} while (true);
}
function getFilesystemRoot() {
if (process.platform !== 'win32') {
return '/';
}
const fsRoot = process.cwd().split(path.sep)[0];
assert(fsRoot);
return fsRoot;
}
function assertTelefuncFilesSource(telefuncFileSource, { telefuncFilePath, project, tsConfigFilePath, appRootDir, }) {
if (telefuncFileSource) {
return;
}
const sourceFiles = project.getSourceFiles().map((sourceFile) =>
// @ts-expect-error
sourceFile._compilerNode.fileName);
if (tsConfigFilePath) {
const userTsFiles = sourceFiles.filter((filePath) => !filePath.includes('__telefunc_'));
const msg1 = `The TypeScript configuration ${tsConfigFilePath} doesn't seem to include`;
const msg2 = `Make sure to configure the ${pc.cyan('include')} and ${pc.cyan('exclude')} (or ${pc.cyan('files')}) options of that tsconfig.json`;
if (userTsFiles.length === 0) {
assertUsage(false, [`${msg1} any file (i.e. it includes 0 files).`, `${msg2} to match at least one file.`].join(' '));
}
else {
assertUsage(false, [
`${msg1} the ${telefuncFilePath} file.`,
`${msg2} to match the ${telefuncFilePath} file.`,
`It currently matches the following files:\n${userTsFiles.map((f) => ` ${f}`).join('\n')}`,
].join(' '));
}
}
else {
const debugInfo = JSON.stringify({
telefuncFilePath,
sourceFiles,
tsConfigFilePath,
appRootDir,
}, null, 2);
assert(false, debugInfo);
}
}