UNPKG

@grpc.ts/cli

Version:

CLI package for @grpc.ts to generate typing for TypeScript

805 lines (781 loc) 27 kB
#!/usr/bin/env node 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();