@grpc.ts/cli
Version:
CLI package for @grpc.ts to generate typing for TypeScript
805 lines (781 loc) • 27 kB
JavaScript
import { glob } from 'glob';
import { resolve } from 'path';
import { load } from 'protobufjs';
import { cwd } from 'node:process';
import { register } from 'ts-node';
import { mkdir as mkdir$1, writeFile as writeFile$1, readFile } from 'node:fs/promises';
import { format as format$1 } from 'prettier';
import { writeFile, mkdir, access, constants } from 'fs/promises';
function parse(namespace, options = {}) {
const { external = [] } = options;
let result = [];
const rootNamespaceData = {
enums: [],
services: [],
messages: [],
};
let packageNamespaceData = [];
Object.entries(namespace).forEach(([rootPackageName, data]) => {
const currentPackageName = rootPackageName;
if (isNamespace(data)) {
const parsedNamespaces = parseNamespace(data.nested, rootPackageName, external);
if (!parsedNamespaces) {
return;
}
packageNamespaceData = packageNamespaceData.concat(parsedNamespaces);
return;
}
if (isMessage(data)) {
const messageProps = parseMessage(data);
return rootNamespaceData.messages.push([
currentPackageName,
messageProps,
]);
}
if (isService(data)) {
const serviceProps = parseService(data);
return rootNamespaceData.services.push([
currentPackageName,
serviceProps,
]);
}
if (isEnum(data)) {
const enumProps = parseEnum(data);
return rootNamespaceData.enums.push([currentPackageName, enumProps]);
}
});
result = result.concat(packageNamespaceData);
result.push(['', rootNamespaceData]);
return result;
}
function parseNamespace(data = {}, rootName, external) {
let skip = false;
const result = {};
Object.entries(data).forEach((datumn) => {
if (isNamespace(datumn[1])) {
const [packageName, nested] = datumn;
const currentPackageName = `${rootName}.${packageName}`;
if (external.length && external.includes(currentPackageName)) {
skip = true;
return;
}
const packageData = nested;
const nestedNamespaces = parseNamespace(packageData.nested, currentPackageName, external);
if (!nestedNamespaces) {
skip = true;
return;
}
nestedNamespaces.forEach((nestedNamespace) => {
const { messages = [], services = [], enums = [], } = result[currentPackageName] || {};
result[nestedNamespace[0]] = {
enums: [].concat(enums, nestedNamespace[1].enums),
messages: [].concat(messages, nestedNamespace[1].messages),
services: [].concat(services, nestedNamespace[1].services),
};
});
return;
}
result[rootName] ||= {
enums: [],
messages: [],
services: [],
};
if (isMessage(datumn[1])) {
const messageProps = parseMessage(datumn[1]);
return result[rootName].messages.push([datumn[0], messageProps]);
}
if (isService(datumn[1])) {
const serviceProps = parseService(datumn[1]);
return result[rootName].services.push([datumn[0], serviceProps]);
}
if (isEnum(datumn[1])) {
const enumProps = parseEnum(datumn[1]);
return result[rootName].enums.push([datumn[0], enumProps]);
}
});
if (skip) {
return null;
}
return Object.entries(result);
}
function isNamespace(data) {
return Object.prototype.hasOwnProperty.call(data, 'nested');
}
function parseMessage(data) {
return Object.entries(data.fields).reduce((result, [fieldName, fieldData]) => {
const { type, rule, options } = fieldData;
const { proto3_optional: optional } = options || {};
result.push({
type,
optional,
name: fieldName,
rule: rule,
});
return result;
}, []);
}
function isMessage(data) {
return Object.prototype.hasOwnProperty.call(data, 'fields');
}
function parseService(data) {
return Object.entries(data.methods).reduce((result, [fieldName, fieldData]) => {
const { requestType, responseType } = fieldData;
result.push({
requestType,
responseType,
name: fieldName,
});
return result;
}, []);
}
function isService(data) {
return Object.prototype.hasOwnProperty.call(data, 'methods');
}
function parseEnum(data) {
return Object.values(data);
}
function isEnum(data) {
return Object.prototype.hasOwnProperty.call(data, 'values');
}
function wrapArray(data) {
return (Array.isArray(data) ? data : [data]);
}
function group(data, callback) {
return data.reduce((result, datumn) => {
result[callback(datumn)] = datumn;
return result;
}, {});
}
async function loadData(config) {
const { paths } = config;
const filePaths = await glob(wrapArray(paths).map((path) => resolve(cwd(), path)));
return Promise.all(filePaths.map(async (filePath) => {
const root = await load(filePath);
const data = root.toJSON().nested;
if (!data) {
console.warn('No data from proto file');
return null;
}
const parsedData = parse(data, config);
return {
filePath,
...parseProtoData(parsedData),
};
}));
}
function parseProtoData(data) {
const result = {
packageName: '',
dependencies: {},
ownedMessages: {},
noDependency: true,
data: {
enums: [],
messages: [],
services: [],
},
};
let index = 0;
const len = data.length - 1;
const last = data[data.length - 1][1];
const firstElement = data[0];
if (last.enums.length || last.messages.length || last.services.length) {
Object.assign(result, {
data: last,
ownedMessages: group(last.messages, (message) => message[0]),
});
}
else {
index += 1;
Object.assign(result, {
data: firstElement[1],
packageName: firstElement[0],
ownedMessages: group(firstElement[1].messages, (message) => message[0]),
});
}
for (index; index < len; index++) {
const element = data[index];
result.noDependency = false;
result.dependencies[element[0]] = element[1];
}
return result;
}
async function loadConfig() {
const service = register({
compilerOptions: {
module: 'CommonJS',
},
moduleTypes: {
'**': 'cjs',
},
});
service.enabled(true);
const path = `${cwd()}/grpc-cli.ts`;
let data = {};
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
data = require(path).default;
}
catch {
// ignore error
}
service.enabled(false);
return {
paths: data.paths || [],
monorepo: data.monorepo,
output: data.output || 'protobufTypings',
external: data.external || ['google.protobuf'],
};
}
function capitalize(data) {
return combine({ joinWith: '' }, data.charAt(0).toUpperCase(), data.slice(1));
}
function camelize(str, opts = {}) {
const { uppercase = false } = opts;
return str?.replace(/^([A-Z])|[\s-_/]+(\w)/g, (_match, p1, p2) => {
if (p2) {
return p2.toUpperCase();
}
const result = p1.toLowerCase();
if (uppercase) {
return capitalize(result);
}
return result;
});
}
function toSnakeCase(str) {
if (!str) {
return str;
}
return lowerFirstChar(str).replace(/[A-Z]/g, (match) => {
return `_${match.toLowerCase()}`;
});
}
function lowerFirstChar(str) {
return str.charAt(0).toLowerCase() + str.slice(1);
}
function compact(source) {
return source.filter((s) => !!s);
}
function combine(opts = '', ...params) {
let options = { joinWith: ' ' };
if (typeof opts === 'object') {
options = opts;
}
else {
params = [opts, ...params];
}
const { joinWith } = options;
return compact(params).join(joinWith);
}
function makeMessageInterface({ packageName, messageName, }) {
return combine({ joinWith: '' }, 'I', packageName
.split('.')
.map((e) => capitalize(e))
.join(''), messageName);
}
function format(content, opts = {}) {
return format$1(content, {
singleQuote: true,
trailingComma: 'all',
jsxSingleQuote: true,
parser: 'typescript',
arrowParens: 'always',
...opts,
});
}
function mergeObj(source, target) {
Object.entries(target).forEach(([key, value]) => {
const sourceVal = source[key];
if (sourceVal) {
if (Array.isArray(sourceVal) && Array.isArray(value)) {
source[key].push(...value);
}
else if (typeof sourceVal === 'object') {
Object.assign(source[key], value);
}
else {
source[key] = value;
}
}
else {
source[key] = value;
}
});
return source;
}
function createImports({ hasBanType, hasService, hasGrpcTimestamp, cachedPackageOutputs, messageDependentTypes, serviceDependentTypes, }) {
if (!hasService && !hasGrpcTimestamp && !hasService) {
return '';
}
const corePackage = '@grpc.ts/core';
const dependentTypes = mergeObj(messageDependentTypes, serviceDependentTypes);
return combine({
joinWith: '\n',
}, hasBanType ? '/* eslint-disable @typescript-eslint/ban-types */' : '', combine('import type {', combine({
joinWith: ', ',
}, hasService ? 'Metadata' : '', hasGrpcTimestamp ? 'GrpcTimestamp' : '', hasService ? 'ServiceClient' : ''), '} from', `'${corePackage}'`), '\n', createImportDependentTypes(dependentTypes, cachedPackageOutputs));
}
function createImportDependentTypes(dependentTypes, cachedPackageOutputs) {
return Object.entries(dependentTypes).reduce((content, [packageName, types]) => {
const output = cachedPackageOutputs[packageName].replace('.ts', '');
content += combine('import type {', types.reduce((result, type) => {
const alias = makeMessageInterface({
packageName,
messageName: type,
});
result += combine(`I${type}`, 'as', alias);
result += ', ';
return result;
}, ''), '} from', `'./${output}';`);
return content;
}, '');
}
function createExportEnums(enums) {
let content = '';
const cachedEnums = {};
enums.forEach((enu) => {
const [enumName, values] = enu;
cachedEnums[enumName] = true;
const valuesAsStrings = values.reduce((result, vals) => {
Object.entries(vals).forEach(([k, v]) => {
const camelizedKey = camelize(k);
result[0] += `${camelizedKey}: ${v},\n`;
result[1] += `| '${camelizedKey}'\n`;
result[2] += `${v}: '${camelizedKey}',\n`;
});
return result;
}, ['', '', '']);
const enumExportName = lowerFirstChar(enumName);
content += `
export const ${enumExportName} = {
${valuesAsStrings[0]}
};
export const ${enumExportName}Mapper = {
${valuesAsStrings[2]}
};
export type T${enumName} = ${valuesAsStrings[1]};
`;
});
return [content, cachedEnums];
}
function createExportMessages({ messages, cachedTypes, cachedEnums, ownedMessages, }) {
const dependentTypes = {};
let hasGrpcTimestamp = false;
const fileContent = messages.reduce((content, message) => {
const [messageName, fields] = message;
const fieldsAsString = fields.reduce((result, { name, optional, type, rule }) => {
const tsKey = [name, optional ? '?' : ''].join('');
const [tsPrimitiveType, packageName, packageMessage] = lookupType$1(type, cachedEnums, cachedTypes, ownedMessages);
if (packageName && packageMessage) {
dependentTypes[packageName] ||= [];
dependentTypes[packageName].push(packageMessage);
}
const tsType = [tsPrimitiveType, rule === 'repeated' ? '[]' : ''].join('');
result += combine({
joinWith: ': ',
}, tsKey, combine({
joinWith: '',
}, tsType, ';'));
if (type === 'google.protobuf.Timestamp') {
hasGrpcTimestamp = true;
}
return result;
}, '');
content += `
export interface I${capitalize(messageName)} {
${fieldsAsString}
}
`;
return content;
}, '');
return [fileContent, hasGrpcTimestamp, dependentTypes];
}
function lookupType$1(type, cachedEnums, cachedTypes, ownedMessages) {
if (cachedEnums[type]) {
return ['number'];
}
if (type === 'google.protobuf.Timestamp') {
return ['GrpcTimestamp'];
}
if (ownedMessages[type]) {
return [convertTypeScriptType(type)];
}
const cachedType = cachedTypes[type];
if (cachedType) {
return [
makeMessageInterface({
packageName: cachedType.packageName,
messageName: cachedType.messageName,
}),
cachedType.packageName,
cachedType.messageName,
];
}
return [convertTypeScriptType(type)];
}
function convertTypeScriptType(type) {
switch (type) {
case 'double':
case 'float':
case 'int32':
case 'int64':
case 'uint32':
case 'uint64':
case 'sint32':
case 'sint64':
case 'fixed32':
case 'fixed64':
case 'sfixed32':
case 'sfixed64':
return 'number';
case 'bool':
return 'boolean';
case 'bytes':
return 'number[]';
case 'string':
return 'string';
default:
if (type.includes('.')) {
return 'unknown';
}
return `I${type}`;
}
}
function createExportServices({ services, cachedTypes, ownedMessages, }) {
const dependentTypes = {};
let hasBanType = false;
const fileContent = services.reduce((content, service) => {
const [serviceName, methods] = service;
const methodsAsString = methods.reduce((result, { name, requestType, responseType }) => {
const [tsType, packageName, packageMessage] = lookupType(requestType, cachedTypes, ownedMessages);
const paramsAndResponse = `(params: ${tsType}, metadata?: Metadata): Promise<I${responseType}>;`;
if (tsType === '{}') {
hasBanType = true;
}
if (packageName && packageMessage) {
dependentTypes[packageName] ||= [];
dependentTypes[packageName].push(packageMessage);
}
result += `${name}${paramsAndResponse}\n`;
result += `${name.charAt(0).toLowerCase() + name.slice(1)}${paramsAndResponse}\n`;
return result;
}, '');
content += `
export const ${toSnakeCase(serviceName).toUpperCase()} = '${serviceName}';
export interface I${serviceName} extends ServiceClient {
${methodsAsString}
}
`;
return content;
}, '');
return [fileContent, hasBanType, dependentTypes];
}
function lookupType(requestType, cachedTypes, ownedMessages) {
if (ownedMessages[requestType]) {
return [`I${capitalize(requestType)}`];
}
if (requestType === 'google.protobuf.Empty') {
return ['{}'];
}
if (cachedTypes[requestType]) {
const { messageName, packageName } = cachedTypes[requestType];
return [
makeMessageInterface({
packageName,
messageName,
}),
packageName,
messageName,
];
}
return ['unknown'];
}
function createExportPackageName(packageName) {
if (packageName) {
return `export const PACKAGE_NAME = '${packageName}'\n\n`;
}
return '';
}
function createMonorepoRootDirPath(config) {
const { packageName, workspacePath } = config;
return combine({
joinWith: '/',
}, workspacePath, packageName);
}
async function createCompilerConfig({ config, generatedFiles = [], }) {
const { compiler, multiEntries } = config;
const { engine, options } = compiler || {};
const rootDirPath = createMonorepoRootDirPath(config);
if (engine === 'tsup') {
createTsupConfig(rootDirPath, options, generatedFiles, multiEntries);
}
}
async function createTsupConfig(rootDirPath, options, generatedFiles = [], multiEntries = false) {
let entry = generatedFiles.map(({ filename }) => filename);
if (multiEntries) {
entry = entry.map((filename) => `src/${filename}.ts`);
}
else {
entry = ['src/index.ts'];
}
const config = mergeObj(mergeObj({
dts: true,
clean: true,
outDir: 'lib',
splitting: false,
}, options || {}), {
entry,
});
const content = `
import { defineConfig } from 'tsup';
export default defineConfig(${JSON.stringify(config)});
`;
await writeFile(`${rootDirPath}/tsup.config.ts`, await format(content));
}
async function createMonorepoStructure({ config, }) {
const { packageName } = config;
const rootDirPath = createMonorepoRootDirPath(config);
await mkdir(`${rootDirPath}/src`, { recursive: true });
await createPackageJson(rootDirPath, packageName);
await createTsConfig(rootDirPath);
return rootDirPath;
}
async function createPackageJson(rootDirPath, packageName) {
const filename = `${rootDirPath}/package.json`;
try {
await access(filename, constants.F_OK);
}
catch {
const content = {
name: packageName,
version: '0.0.1',
license: 'MIT',
};
await writeFile(filename, JSON.stringify(content), 'utf-8');
console.log(`Created ${filename}`);
}
}
async function createTsConfig(rootDirPath) {
const filename = `${rootDirPath}/tsconfig.json`;
try {
await access(filename, constants.F_OK);
}
catch {
const content = {
compilerOptions: {
allowJs: true,
allowSyntheticDefaultImports: true,
declaration: true,
esModuleInterop: true,
experimentalDecorators: true,
forceConsistentCasingInFileNames: true,
importHelpers: true,
isolatedModules: true,
module: 'esnext',
moduleResolution: 'node',
noEmit: true,
noUncheckedIndexedAccess: true,
outDir: 'lib',
removeComments: true,
resolveJsonModule: true,
skipLibCheck: true,
sourceMap: false,
strict: true,
target: 'esnext',
verbatimModuleSyntax: true,
},
include: ['src'],
exclude: ['lib', 'node_modules'],
};
await writeFile(filename, await format(JSON.stringify(content), {
parser: 'json',
}));
console.log(`Created ${filename}`);
}
}
async function createContent(groupedData, config) {
const { output, buildDir = 'dist', monorepo } = config;
const cachedTypes = {};
const cachedPackageOutputs = {};
let outputDir = output;
let monorepoRootDir;
const generatedFilenames = [];
if (monorepo) {
monorepoRootDir = createMonorepoRootDirPath(monorepo);
outputDir = combine({ joinWith: '/' }, monorepoRootDir, 'src');
await createMonorepoStructure({ config: monorepo });
}
for (const groupedDatumn of groupedData) {
await Promise.all(groupedDatumn.map(async (protoData) => {
const { data, filePath, packageName, ownedMessages } = protoData;
const { enums, messages, services } = data;
const packageNameContent = createExportPackageName(packageName);
const [enumsContent, cachedEnums] = createExportEnums(enums);
const [messagesContent, hasGrpcTimestamp, messageDependentTypes] = createExportMessages({
messages,
cachedEnums,
cachedTypes,
ownedMessages,
});
const [servicesContent, hasBanType, serviceDependentTypes] = createExportServices({
services,
cachedTypes,
ownedMessages,
});
const importsContent = createImports({
hasBanType,
hasGrpcTimestamp,
cachedPackageOutputs,
messageDependentTypes,
serviceDependentTypes,
hasService: services.length > 0,
});
const fileContent = combine({ joinWith: '\n' }, importsContent, '\n', packageNameContent, enumsContent, messagesContent, servicesContent);
Object.keys(ownedMessages).forEach((ownedMessage) => {
const key = combine({ joinWith: '.' }, packageName, ownedMessage);
cachedTypes[key] = {
filePath,
packageName,
messageName: ownedMessage,
};
});
const fileNameWithoutExt = combine({
joinWith: '.',
}, filePath.slice(filePath.lastIndexOf('/') + 1, filePath.length - '.proto'.length), 'interface');
const fileName = combine({
joinWith: '.',
}, fileNameWithoutExt, 'ts');
cachedPackageOutputs[packageName] = fileName;
await mkdir$1(outputDir, { recursive: true });
const interfaceFilename = `${outputDir}/${fileName}`;
generatedFilenames.push({
packageName,
filename: fileNameWithoutExt,
});
writeFile$1(interfaceFilename, await format(fileContent), {
encoding: 'utf8',
}).then(() => {
console.log(`Created ${interfaceFilename}`);
});
}));
}
if (monorepo) {
const { multiEntries, compiler } = monorepo;
const { engine, options } = compiler || {};
const hasEngine = !!engine;
let buildDirectory = buildDir;
if (options?.outDir) {
buildDirectory = options.outDir;
}
await createCompilerConfig({
config: monorepo,
generatedFiles: generatedFilenames,
});
const { content, exports, typesVersions } = generatedFilenames.reduce((result, { packageName, filename }) => {
result.content.push(`export * from './${filename}';`);
result.exports[`./${packageName}`] =
`./${buildDirectory}/${filename}.${hasEngine ? 'js' : 'ts'}`;
result.typesVersions[`${packageName}`] = [
`./${buildDirectory}/${filename}.${hasEngine ? 'd.ts' : 'ts'}`,
];
return result;
}, {
exports: {},
content: [],
typesVersions: {},
});
let packageExports = {};
if (!multiEntries) {
const singleEntryFilename = `${monorepoRootDir}/src/index.ts`;
await writeFile$1(singleEntryFilename, combine({
joinWith: '\n',
}, ...content));
packageExports = {
main: singleEntryFilename,
types: singleEntryFilename,
};
}
else {
packageExports = {
exports,
typesVersions: {
'*': typesVersions,
},
};
}
switch (engine) {
case 'tsup': {
packageExports.scripts = {
build: 'tsup',
};
packageExports.devDependencies = {
tsup: '^8.0.2',
};
break;
}
}
const packageJson = JSON.parse(await readFile(`${monorepoRootDir}/package.json`, 'utf-8'));
Object.assign(packageJson, packageExports);
await writeFile$1(`${monorepoRootDir}/package.json`, await format(JSON.stringify(packageJson), {
parser: 'json',
}), 'utf-8');
}
}
function topologicalGroup(protoData) {
const visited = {};
const result = [];
while (protoData.length) {
const removedIndexes = [];
for (let i = 0, n = protoData.length; i < n; i++) {
const protoDatumn = protoData[i];
if (!protoDatumn) {
removedIndexes.push(i);
continue;
}
const { packageName, filePath, dependencies } = protoDatumn;
const dependencyList = Object.keys(dependencies);
if (!dependencyList.length) {
result[0] ||= [];
result[0].push(protoDatumn);
removedIndexes.push(i);
visited[packageName || filePath] = {
level: 0,
visited: true,
};
continue;
}
let level = 0;
let visitedAllDependencies = true;
dependencyList.forEach((dependency) => {
if (visited[dependency]) {
level = Math.max(level, visited[dependency].level);
}
else {
visitedAllDependencies = false;
}
});
if (visitedAllDependencies) {
result[level + 1] ||= [];
result[level + 1].push(protoDatumn);
removedIndexes.push(i);
visited[packageName || filePath] = {
visited: true,
level: level + 1,
};
}
}
removedIndexes.reverse().forEach((index) => {
protoData.splice(index, 1);
});
}
return result;
}
async function generate() {
const config = await loadConfig();
const protoData = await loadData(config);
const groupedProtoData = topologicalGroup(protoData);
createContent(groupedProtoData, config);
}
generate();