diff --git a/package.json b/package.json index 67d78d6880..ec636207d9 100644 --- a/package.json +++ b/package.json @@ -330,7 +330,7 @@ "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production", - "protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs", + "protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs", "postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn", "compile-tests": "node ./scripts/build-tests.js", "watch-tests": "tsc -p . -w --outDir out", diff --git a/scripts/generate-host-bridge-client.mjs b/scripts/generate-host-bridge-client.mjs new file mode 100755 index 0000000000..ba2e9a5461 --- /dev/null +++ b/scripts/generate-host-bridge-client.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import * as fs from "fs/promises" +import * as path from "path" +import * as grpc from "@grpc/grpc-js" +import * as protoLoader from "@grpc/proto-loader" +import chalk from "chalk" + +const IMPL_FILE = path.resolve("src/generated/standalone/host-bridge-clients.ts") +const INTERFACE_FILE = path.resolve("src/generated/hosts/host-bridge-client-types.ts") +const DESCRIPTOR_SET = path.resolve("dist-standalone/proto/descriptor_set.pb") + +const typeNameToFQN = new Map() + +function addTypeNameToFqn(name, fqn) { + if (typeNameToFQN.has(name)) { + throw new Error(`Proto type ${name} redefined (${fqn}).`) + } + typeNameToFQN.set(name, fqn) +} +function getFqn(name) { + if (!typeNameToFQN.has(name)) { + throw Error(`No FQN for ${name}`) + } + return typeNameToFQN.get(name) +} +/** + * Main function to generate the host bridge client + */ +async function main() { + // Load service definitions from descriptor set + const descriptorBuffer = await fs.readFile(DESCRIPTOR_SET) + const packageDefinition = protoLoader.loadFileDescriptorSetFromBuffer(descriptorBuffer) + const proto = grpc.loadPackageDefinition(packageDefinition) + + // Extract host services and proto messages from the proto definition + const hostServices = {} + for (const [name, def] of Object.entries(proto.host)) { + if (def && "service" in def) { + hostServices[name] = def + } else { + addTypeNameToFqn(name, `proto.host.${name}`) + } + } + for (const [name, def] of Object.entries(proto.cline)) { + if (def && !("service" in def)) { + addTypeNameToFqn(name, `proto.cline.${name}`) + } + } + + // Generate interfaces file + await generateInterfacesFile(hostServices) + + // // Generate implementation file + await generateImplementationFile(hostServices) + + console.log(`Generated host bridge client files at:`) + console.log(`- ${INTERFACE_FILE}`) + console.log(`- ${IMPL_FILE}`) +} + +/** + * Generate the client interfaces file. + */ +async function generateInterfacesFile(hostServices) { + const clientInterfaces = [] + for (const [name, def] of Object.entries(hostServices)) { + const clientInterface = generateClientInterface(name, def) + clientInterfaces.push(clientInterface) + } + const content = `// GENERATED CODE -- DO NOT EDIT! +// Generated by scripts/generate-host-bridge-client.mjs +import * as proto from "@shared/proto/index" +import { StreamingCallbacks } from "@hosts/vscode/host-grpc-handler" + +${clientInterfaces.join("\n\n")} +` + // Write output file + await fs.mkdir(path.dirname(INTERFACE_FILE), { recursive: true }) + await fs.writeFile(INTERFACE_FILE, content) +} + +/** + * Generate a client interface for a service. + */ +function generateClientInterface(serviceName, serviceDefinition) { + // Get the methods from the service definition + const methods = Object.entries(serviceDefinition.service) + .map(([methodName, methodDef]) => { + const requestType = getFqn(methodDef.requestType.type.name) + const responseType = getFqn(methodDef.responseType.type.name) + + if (!methodDef.responseStream) { + // Generate unary method signature. + return ` ${methodName}(request: ${requestType}): Promise<${responseType}>;` + } + // Generate streaming method signature. + return ` ${methodName}(request: ${requestType}, callbacks: StreamingCallbacks<${responseType}>): () => void;` + }) + .join("\n\n") + + // Generate the interface + return `/** + * Interface for ${serviceName} client. + */ +export interface ${serviceName}ClientInterface { + +${methods} +}` +} + +/** + * Generate the client implementations file. + */ +async function generateImplementationFile(hostServices) { + // Generate imports + const imports = [] + // Add imports for the interfaces + for (const [name, _def] of Object.entries(hostServices)) { + imports.push(`import { ${name}ClientInterface } from "@generated/hosts/host-bridge-client-types"`) + } + const clientImplementations = [] + for (const [name, def] of Object.entries(hostServices)) { + clientImplementations.push(generateClientImplementation(name, def)) + } + + const content = `// GENERATED CODE -- DO NOT EDIT! +// Generated by scripts/generate-host-bridge-client.mjs +import { asyncIteratorToCallbacks } from "@/standalone/utils" +import * as niceGrpc from "@generated/nice-grpc/index" +import { StreamingCallbacks } from "@hosts/vscode/host-grpc-handler" +import * as proto from "@shared/proto/index" +import { Channel, createClient } from "nice-grpc" + +${imports.join("\n")} + +${clientImplementations.join("\n\n")} +` + + // Write output file + await fs.mkdir(path.dirname(IMPL_FILE), { recursive: true }) + await fs.writeFile(IMPL_FILE, content) +} + +/** + * Generate a client implementation class for a service + */ +function generateClientImplementation(serviceName, serviceDefinition) { + // Get the methods from the service definition + const methods = Object.entries(serviceDefinition.service) + .map(([methodName, methodDef]) => { + // Get fully qualified type names + const requestType = getFqn(methodDef.requestType.type.name) + const responseType = getFqn(methodDef.responseType.type.name) + const isStreamingResponse = methodDef.responseStream + + if (!isStreamingResponse) { + return ` ${methodName}(request: ${requestType}): Promise<${responseType}> { + return this.client.${methodName}(request) + }` + } else { + // Generate streaming method + return ` ${methodName}(request: ${requestType}, callbacks: StreamingCallbacks<${responseType}>): () => void { + const abortController = new AbortController() + const stream: AsyncIterable<${responseType}> = this.client.${methodName}(request, {signal: abortController.signal}) + asyncIteratorToCallbacks(stream, callbacks) + return () => {abortController.abort()} + }` + } + }) + .join("\n\n") + + // Generate the class + return `/** + * Type-safe client implementation for ${serviceName}. + */ +export class ${serviceName}ClientImpl implements ${serviceName}ClientInterface { + private client: niceGrpc.host.${serviceName}Client + + constructor(channel: Channel) { + this.client = createClient(niceGrpc.host.${serviceName}Definition, channel) + } + +${methods} +}` +} + +// Run the main function +main().catch((error) => { + console.error(chalk.red("Error:"), error) + process.exit(1) +}) diff --git a/src/hosts/host-bridge-client.ts b/src/hosts/host-bridge-client.ts index fe53d0e7f0..bb70df2d07 100644 --- a/src/hosts/host-bridge-client.ts +++ b/src/hosts/host-bridge-client.ts @@ -1,7 +1,8 @@ +import { UriServiceClientInterface, WatchServiceClientInterface } from "@/generated/hosts/host-bridge-client-types" import * as VscodeClient from "./vscode/client/host-grpc-client" import * as ExternalClient from "@/standalone/host-bridge-client-manager" const isHostBridgeExternal = process.env.HOST_BRIDGE_ADDRESS !== undefined && process.env.HOST_BRIDGE_ADDRESS !== "vscode" const Client = isHostBridgeExternal ? ExternalClient : VscodeClient -export const UriServiceClient = Client.UriServiceClient -export const WatchServiceClient = Client.WatchServiceClient +export const UriServiceClient: UriServiceClientInterface = Client.UriServiceClient +export const WatchServiceClient: WatchServiceClientInterface = Client.WatchServiceClient diff --git a/src/standalone/host-bridge-client-manager.ts b/src/standalone/host-bridge-client-manager.ts index e0df1d7b4a..2670b910da 100644 --- a/src/standalone/host-bridge-client-manager.ts +++ b/src/standalone/host-bridge-client-manager.ts @@ -1,5 +1,7 @@ import { Channel, createChannel, createClient } from "nice-grpc" import * as host from "@generated/nice-grpc/index.host" +import { UriServiceClientInterface, WatchServiceClientInterface } from "@/generated/hosts/host-bridge-client-types" +import { FileChangeEvent } from "@/shared/proto/index.host" /** * Singleton class to hold the gRPC clients for the host bridge. The clients should be re-used to avoid @@ -36,7 +38,7 @@ const StubWatchServiceClient = { subscribeToFile: function ( _r: host.SubscribeToFileRequest, _h: { - onResponse?: (response: { type: host.FileChangeEvent_ChangeType }) => void | Promise + onResponse?: (response: FileChangeEvent) => void | Promise onError?: (error: any) => void onComplete?: () => void }, @@ -47,5 +49,5 @@ const StubWatchServiceClient = { const clientManager = HostBridgeClientManager.getInstance() -export const UriServiceClient = clientManager.uriClient -export const WatchServiceClient = StubWatchServiceClient +export const UriServiceClient: UriServiceClientInterface = clientManager.uriClient +export const WatchServiceClient: WatchServiceClientInterface = StubWatchServiceClient diff --git a/src/standalone/utils.ts b/src/standalone/utils.ts index 0c17ee7979..9b3ca68bed 100644 --- a/src/standalone/utils.ts +++ b/src/standalone/utils.ts @@ -2,6 +2,7 @@ import * as fs from "fs" import * as grpc from "@grpc/grpc-js" import * as protoLoader from "@grpc/proto-loader" import * as health from "grpc-health-check" +import { StreamingCallbacks } from "@hosts/vscode/host-grpc-handler" const log = (...args: unknown[]) => { const timestamp = new Date().toISOString() @@ -16,4 +17,28 @@ function getPackageDefinition() { const packageDefinition = { ...clineDef, ...healthDef } return packageDefinition } -export { getPackageDefinition, log } + +/** + * Converts an AsyncIterable to a callback-based API + * @param stream The AsyncIterable stream to process + * @param callbacks The callbacks to invoke for stream events + */ +async function asyncIteratorToCallbacks(stream: AsyncIterable, callbacks: StreamingCallbacks): Promise { + try { + // Process each item in the stream + for await (const response of stream) { + callbacks.onResponse && callbacks.onResponse(response) + } + // Stream completed successfully + callbacks.onComplete && callbacks.onComplete() + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + if (callbacks.onError) { + callbacks.onError(error) + } else { + log(`Host bridge RPC error: ${error}`) + } + } +} + +export { getPackageDefinition, log, asyncIteratorToCallbacks }