Add type checking for protobus RPC handlers. (#4020)

* Add type checking for protobus RPC handlers.

Add type parameters to the wrapper function when generating the server-setup file.
So that the parameters of the RPC handler will be type checked against types defined in the service.
The generated code looks like this:
```
    server.addService(proto.cline.CheckpointsService.service, {
         checkpointDiff: wrapper<cline.Int64Request,cline.Empty>(checkpointDiff, controller),
         checkpointRestore: wrapper<cline.CheckpointRestoreRequest,cline.Empty>(checkpointRestore, controller),
    });
```

Add an index.ts file to the proto directory so all the proto types can be imported without having to know which proto message is defined in which file (the proto descriptor set doesn't have this information).

* Generate index.ts with protoc instead of doing it ourselves.

Turn on the option 'dontExportCommonSymbols' in protoc, so that each generated proto file is not trying to export the same utility functions.

Generate all the protos with the same protoc command.

* cleanup
This commit is contained in:
Sarah Fortune
2025-06-04 17:42:19 -07:00
committed by GitHub
parent cbcd17764b
commit b7096a77db
3 changed files with 41 additions and 68 deletions
+35 -64
View File
@@ -12,38 +12,6 @@ import { createRequire } from "module"
const require = createRequire(import.meta.url)
const protoc = path.join(require.resolve("grpc-tools"), "../bin/protoc")
// Check for Apple Silicon compatibility
function checkAppleSiliconCompatibility() {
// Only run check on macOS
if (process.platform !== "darwin") {
return
}
// Check if running on Apple Silicon
const cpuArchitecture = os.arch()
if (cpuArchitecture === "arm64") {
try {
// Check if Rosetta is installed
const rosettaCheck = execSync('/usr/bin/pgrep oahd || echo "NOT_INSTALLED"').toString().trim()
if (rosettaCheck === "NOT_INSTALLED") {
console.log(chalk.yellow("Detected Apple Silicon (ARM64) architecture."))
console.log(
chalk.red("Rosetta 2 is NOT installed. The npm version of protoc is not compatible with Apple Silicon."),
)
console.log(chalk.cyan("Please install Rosetta 2 using the following command:"))
console.log(chalk.cyan(" softwareupdate --install-rosetta --agree-to-license"))
console.log(chalk.red("Aborting build process."))
process.exit(1)
} else {
console.log(chalk.green("Rosetta 2 is installed. Continuing with build."))
}
} catch (error) {
console.log(chalk.yellow("Could not determine Rosetta installation status. Proceeding anyway."))
}
}
}
const __filename = fileURLToPath(import.meta.url)
const SCRIPT_DIR = path.dirname(__filename)
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
@@ -89,11 +57,9 @@ async function main() {
// Define output directories
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
const HOST_TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto", "host")
// Create output directories if they don't exist
await fs.mkdir(TS_OUT_DIR, { recursive: true })
await fs.mkdir(HOST_TS_OUT_DIR, { recursive: true })
// Clean up existing generated files
console.log(chalk.cyan("Cleaning up existing generated TypeScript files..."))
@@ -105,9 +71,9 @@ async function main() {
// Check for missing proto files for services in serviceNameMap
await ensureProtoFilesExist()
// Process main proto files
// Process all proto files
console.log(chalk.cyan("Processing proto files from"), SCRIPT_DIR)
const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR, realpath: true })
const protoFiles = await globby("**/*.proto", { cwd: SCRIPT_DIR, realpath: true })
// Build the protoc command with proper path handling for cross-platform
const tsProtocCommand = [
@@ -115,6 +81,8 @@ async function main() {
`--proto_path="${SCRIPT_DIR}"`,
`--plugin=protoc-gen-ts_proto="${tsProtoPlugin}"`,
`--ts_proto_out="${TS_OUT_DIR}"`,
"--ts_proto_opt=exportCommonSymbols=false",
"--ts_proto_opt=outputIndex=true",
"--ts_proto_opt=outputServices=generic-definitions,env=node,esModuleInterop=true,useDate=false,useOptionals=messages",
...protoFiles,
].join(" ")
@@ -126,42 +94,15 @@ async function main() {
process.exit(1)
}
// Process host proto files
console.log(chalk.cyan("Processing host proto files from"), path.join(SCRIPT_DIR, "host"))
const hostProtoFiles = await globby("*.proto", { cwd: path.join(SCRIPT_DIR, "host"), absolute: true })
if (hostProtoFiles.length > 0) {
// Build the protoc command for host proto files
const hostTsProtocCommand = [
protoc,
`--proto_path="${SCRIPT_DIR}"`,
`--proto_path="${path.join(SCRIPT_DIR, "host")}"`,
`--plugin=protoc-gen-ts_proto="${tsProtoPlugin}"`,
`--ts_proto_out="${TS_OUT_DIR}"`,
"--ts_proto_opt=outputServices=generic-definitions,env=node,esModuleInterop=true,useDate=false,useOptionals=messages",
...hostProtoFiles,
].join(" ")
try {
console.log(chalk.cyan(`Generating TypeScript code for host proto files:\n${hostProtoFiles.join("\n")}...`))
execSync(hostTsProtocCommand, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating TypeScript for host proto files:"), error)
process.exit(1)
}
}
const descriptorOutDir = path.join(ROOT_DIR, "dist-standalone", "proto")
await fs.mkdir(descriptorOutDir, { recursive: true })
const descriptorFile = path.join(descriptorOutDir, "descriptor_set.pb")
const allProtoFiles = [...protoFiles, ...hostProtoFiles]
const descriptorProtocCommand = [
protoc,
`--proto_path="${SCRIPT_DIR}"`,
`--proto_path="${path.join(SCRIPT_DIR, "host")}"`,
`--descriptor_set_out="${descriptorFile}"`,
"--include_imports",
...allProtoFiles,
...protoFiles,
].join(" ")
try {
console.log(chalk.cyan("Generating descriptor set..."))
@@ -702,6 +643,36 @@ export {
console.log(chalk.green(`Generated host gRPC client at ${configPath}`))
}
// Check for Apple Silicon compatibility
function checkAppleSiliconCompatibility() {
// Only run check on macOS
if (process.platform !== "darwin") {
return
}
// Check if running on Apple Silicon
const cpuArchitecture = os.arch()
if (cpuArchitecture === "arm64") {
try {
// Check if Rosetta is installed
const rosettaCheck = execSync('/usr/bin/pgrep oahd || echo "NOT_INSTALLED"').toString().trim()
if (rosettaCheck === "NOT_INSTALLED") {
console.log(chalk.yellow("Detected Apple Silicon (ARM64) architecture."))
console.log(
chalk.red("Rosetta 2 is NOT installed. The npm version of protoc is not compatible with Apple Silicon."),
)
console.log(chalk.cyan("Please install Rosetta 2 using the following command:"))
console.log(chalk.cyan(" softwareupdate --install-rosetta --agree-to-license"))
console.log(chalk.red("Aborting build process."))
process.exit(1)
}
} catch (error) {
console.log(chalk.yellow("Could not determine Rosetta installation status. Proceeding anyway."))
}
}
}
// Run the main function
main().catch((error) => {
console.error(chalk.red("Error:"), error)
+5 -3
View File
@@ -32,13 +32,15 @@ function generateHandlersAndExports() {
handlerSetup.push(` server.addService(proto.cline.${name}.service, {`)
for (const [rpcName, rpc] of Object.entries(def.service)) {
imports.push(`import { ${rpcName} } from "../core/controller/${dir}/${rpcName}"`)
const requestType = "proto.cline." + rpc.requestType.type.name
if (rpc.requestStream) {
throw new Error("Request streaming is not supported")
}
if (rpc.responseStream) {
handlerSetup.push(` ${rpcName}: wrapStreamingResponse(${rpcName}, controller),`)
handlerSetup.push(` ${rpcName}: wrapStreamingResponse<${requestType},void>(${rpcName}, controller),`)
} else {
handlerSetup.push(` ${rpcName}: wrapper(${rpcName}, controller),`)
const responseType = "proto.cline." + rpc.responseType.type.name
handlerSetup.push(` ${rpcName}: wrapper<${requestType},${responseType}>(${rpcName}, controller),`)
}
}
handlerSetup.push(` });`)
@@ -58,11 +60,11 @@ const scriptName = path.basename(fileURLToPath(import.meta.url))
let output = `// GENERATED CODE -- DO NOT EDIT!
// Generated by ${scriptName}
import * as grpc from "@grpc/grpc-js"
import * as proto from "@/shared/proto"
import { Controller } from "../core/controller"
import { GrpcHandlerWrapper, GrpcStreamingResponseHandlerWrapper } from "./grpc-types"
${imports}
export function addServices(
server: grpc.Server,
proto: any,
@@ -46,7 +46,7 @@ export async function sendAccountButtonClickedEvent(controllerId: string): Promi
}
try {
const event: Empty = {}
const event: Empty = Empty.create({})
await responseStream(event, false)
} catch (error) {
console.error(`Error sending account button clicked event to controller ${controllerId}:`, error)