import { Inject, Injectable } from '@nestjs/common'; import type { ClientReadableStream } from '@grpc/grpc-js'; import type { ChatEvent, ChatRequest, ChatServiceClient } from '../gen/apf-ai/chat'; import { GrpcMetadataBuilder } from './grpc-metadata.builder'; import { AI_CHAT_GRPC_CLIENT } from './tokens'; /** * Wrapper around the generated `ChatServiceClient` for the * server-streaming `Chat` RPC, per ADR-0024 §"Sub-decision 1". * * The wrapper does three things on top of the raw gRPC stub: * * 1. Injects the `x-client-id` + `x-correlation-id` metadata via * `GrpcMetadataBuilder` so every call carries the contract * headers from `apf-ai-service/docs/contract.md`. * 2. Wires an optional `AbortSignal` to `call.cancel()` so the SSE * bridge (next PR in the chantier) can propagate browser * disconnects up to the AI service in one line. * 3. Returns the raw `ClientReadableStream` — Node * `Readable` streams are async-iterable by default, so the SSE * bridge consumes the stream with `for await ... of` and no * intermediate adapter. * * Cancellation flows in both directions: a `.cancel()` from inside * the BFF (timeout, abort) reaches the AI service as a * `grpc.status.CANCELLED`, which translates upstream to * `ServerCallContext.CancellationToken` stopping LLM generation * (per `apf-ai-service/docs/streaming.md`). */ @Injectable() export class ChatClient { constructor( @Inject(AI_CHAT_GRPC_CLIENT) private readonly grpc: ChatServiceClient, private readonly metadata: GrpcMetadataBuilder, ) {} /** * Start a streaming Chat call. * * The returned `ClientReadableStream` emits one event * per AI service `ChatEvent` and ends after the terminal * `ChatEvent.done` (or on cancellation / error). * * @param request The full proto request, already populated with * the conversation history and a `Principal` (build the * principal via `PrincipalMapper.fromInputs`). * @param options.signal Optional AbortSignal. When it aborts, the * underlying gRPC call is cancelled and the stream closes. * @param options.correlationId Override the metadata * correlation-id (defaults to the active OTel trace-id). */ chat( request: ChatRequest, options: { signal?: AbortSignal; correlationId?: string } = {}, ): ClientReadableStream { const metadata = this.metadata.build({ correlationId: options.correlationId }); const call = this.grpc.chat(request, metadata); if (options.signal) { if (options.signal.aborted) { call.cancel(); } else { options.signal.addEventListener('abort', () => call.cancel(), { once: true }); } } return call; } }