Files
create-glstack/template/scripts/gen-rpc-index.ts
2026-04-16 13:35:26 +02:00

367 lines
9.5 KiB
TypeScript

import ts, { StringLiteral } from "typescript";
import { resolve, relative, dirname, join } from "path";
import { Glob } from "bun";
const RPC_PKG = resolve(import.meta.dirname!, "../packages/rpc");
const SRC_DIR = join(RPC_PKG, "src");
const INDEX_FILE = join(RPC_PKG, "index.ts");
const glob = new Glob("**/*_pb.ts");
const files = Array.from(glob.scanSync({ cwd: SRC_DIR, absolute: true })).sort();
if (files.length === 0) {
console.error("No _pb.ts files found in", SRC_DIR);
process.exit(1);
}
// Read the rpc package's tsconfig so module resolution finds @bufbuild/protobuf
const tsconfigPath = join(RPC_PKG, "tsconfig.json");
const { config } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, RPC_PKG);
const program = ts.createProgram(files, {
...parsed.options,
noEmit: true,
});
const checker = program.getTypeChecker();
const factory = ts.factory;
function hasTypeNamed(type: ts.Type, name: string): boolean {
if (type.aliasSymbol?.name === name) return true;
if (type.symbol?.name === name) return true;
const target = (type as any).target as ts.Type | undefined;
if (target?.symbol?.name === name) return true;
if (type.isIntersection()) {
return type.types.some((t) => hasTypeNamed(t, name));
}
if (type.isUnion()) {
return type.types.some((t) => hasTypeNamed(t, name));
}
return false;
}
const isStringLiteral = (s: string | StringLiteral | undefined): s is StringLiteral => {
if (s === undefined) {
return false;
}
return (s as StringLiteral).forEachChild != undefined;
}
const serviceNameToRouterPropertyName = (n: string): string => {
return n.toLowerCase().replace('service', '') + 's'
}
const exportDecl = (exp: Map<string, boolean>, typesOnly: boolean = false, from?: string | StringLiteral) => {
return factory.createExportDeclaration(
undefined,
typesOnly,
factory.createNamedExports(
Array.from(exp.entries()).map((exp) => {
return factory.createExportSpecifier(
exp[1],
undefined,
exp[0]
)
})
),
(
isStringLiteral(from) ?
from :
(
from != undefined ?
factory.createStringLiteral(from) :
from
)
)
)
}
const importDecl = (imp: Map<string, boolean>, from: string | StringLiteral, typesOnly: boolean = false) => {
return factory.createImportDeclaration(
undefined,
factory.createImportClause(
typesOnly,
undefined,
factory.createNamedImports(
Array.from(imp.entries()).map((k) => {
return factory.createImportSpecifier(
k[1],
undefined,
factory.createIdentifier(k[0])
)
})
)
),
(isStringLiteral(from) ? from : factory.createStringLiteral(from))
)
}
const statements: ts.Statement[] = [];
const serviceNames: string[] = [];
const typeNames: string[] = [];
for (const filePath of files) {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) continue;
const symbol = checker.getSymbolAtLocation(sourceFile);
if (!symbol) continue;
const exports = checker.getExportsOfModule(symbol);
const fileServiceNames: string[] = [];
const fileTypeNames: string[] = [];
for (const exp of exports) {
const declarations = exp.getDeclarations();
if (!declarations || declarations.length === 0) continue;
const decl = declarations[0];
if (ts.isTypeAliasDeclaration(decl) || ts.isInterfaceDeclaration(decl)) {
const type = checker.getDeclaredTypeOfSymbol(exp);
if (hasTypeNamed(type, "Message")) {
fileTypeNames.push(exp.getName());
}
} else {
const type = checker.getTypeOfSymbolAtLocation(exp, decl);
if (hasTypeNamed(type, "GenService")) {
fileServiceNames.push(exp.getName());
}
}
}
let modulePath = "./" + relative(dirname(INDEX_FILE), filePath);
modulePath = modulePath.replace(/\.ts$/, "");
const moduleSpecifier = factory.createStringLiteral(modulePath);
statements.push(
importDecl(
new Map<string, boolean>([
["Client", true],
["createClient", false]
]),
"@connectrpc/connect"
)
)
statements.push(
importDecl(
new Map<string, boolean>([
["Message", true],
]),
"@bufbuild/protobuf"
)
)
statements.push(
importDecl(
new Map<string, boolean>([
["createConnectTransport", false]
]),
"@connectrpc/connect-web"
)
)
if (fileServiceNames.length > 0) {
serviceNames.push(...fileServiceNames)
statements.push(importDecl(
new Map<string, boolean>(
fileServiceNames.map((v) => {
return [v, false] as const;
})
),
moduleSpecifier
))
}
if (fileTypeNames.length > 0) {
typeNames.push(...fileTypeNames)
statements.push(importDecl(
new Map<string, boolean>(
fileTypeNames.map((v) => {
return [v, false] as const;
})
),
moduleSpecifier,
true
))
}
}
statements.push(
exportDecl(
new Map<string, boolean>(serviceNames.map((service) => {
return [service, false] as const;
}))
)
)
statements.push(
exportDecl(
new Map<string, boolean>(typeNames.map((type) => {
return [type, false] as const;
})),
true
)
)
statements.push(
factory.createInterfaceDeclaration(
undefined,
factory.createIdentifier("Router"),
undefined,
undefined,
serviceNames.map((service) => {
return factory.createPropertySignature(
undefined,
serviceNameToRouterPropertyName(service),
undefined,
factory.createTypeReferenceNode(
factory.createIdentifier("Client"),
[
factory.createTypeQueryNode(
factory.createIdentifier(service)
)
]
)
)
})
)
)
statements.push(exportDecl(
new Map<string, boolean>([
['Router', true]
])
))
statements.push(factory.createFunctionDeclaration(
undefined,
undefined,
'createRouter',
undefined,
[
factory.createParameterDeclaration(
undefined,
undefined,
'baseUrl',
undefined,
factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
undefined
)
],
factory.createTypeReferenceNode(factory.createIdentifier("Router"), undefined),
factory.createBlock(
[
factory.createVariableStatement(
undefined,
factory.createVariableDeclarationList(
[
factory.createVariableDeclaration(
'transport',
undefined,
undefined,
factory.createCallExpression(
factory.createIdentifier("createConnectTransport"),
undefined,
[
factory.createObjectLiteralExpression([
factory.createPropertyAssignment(
'baseUrl',
factory.createIdentifier('baseUrl')
)
])
]
)
),
],
ts.NodeFlags.Const
),
),
factory.createVariableStatement(
undefined,
factory.createVariableDeclarationList(
[
factory.createVariableDeclaration(
'router',
undefined,
undefined,
factory.createObjectLiteralExpression(
[
...serviceNames.map((service) => {
return factory.createPropertyAssignment(
serviceNameToRouterPropertyName(service),
factory.createCallExpression(
factory.createIdentifier('createClient'),
undefined,
[
factory.createIdentifier(service),
factory.createIdentifier('transport')
]
)
)
})
]
)
)
],
ts.NodeFlags.Const
)
),
factory.createReturnStatement(
factory.createIdentifier('router')
)
],
true
)
))
statements.push(exportDecl(new Map<string, boolean>([
['createRouter', false]
])))
statements.push(
ts.factory.createTypeAliasDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier("ExtractPayload"),
[
ts.factory.createTypeParameterDeclaration(
undefined,
ts.factory.createIdentifier("T"),
undefined,
undefined
),
],
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier("Omit"),
[
// T
ts.factory.createTypeReferenceNode("T", undefined),
// keyof Message<any>
ts.factory.createTypeOperatorNode(
ts.SyntaxKind.KeyOfKeyword,
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier("Message"),
[
ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
]
)
),
]
)
)
)
const outputFile = ts.createSourceFile(INDEX_FILE, "", ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const header = "// @generated by scripts/gen-rpc-index.ts — do not edit manually\n\n";
const body = statements
.map((s) => printer.printNode(ts.EmitHint.Unspecified, s, outputFile))
.join("\n");
await Bun.write(INDEX_FILE, header + body + "\n");
console.log(`Wrote ${INDEX_FILE}`);