/** * Request/response pattern over pub/sub. * * Sends a request with a unique ID, subscribes for matching responses. * Supports streaming (multiple responses per request) via a recipient callback. * * Python reference: trustgraph-base/trustgraph/base/request_response_spec.py */ import { Effect, Exit, Scope } from "effect"; import type { PubSubBackend } from "../backend/types.js"; import { PubSub } from "../backend/pubsub.js"; import { messagingDeliveryError, messagingLifecycleError } from "../errors.js"; import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js"; import { makeEffectRequestResponseFromPubSub, type EffectRequestResponse } from "./runtime.js"; export interface RequestResponseOptions { pubsub: PubSubBackend; requestTopic: string; responseTopic: string; subscription: string; } export interface RequestResponse { readonly start: () => Promise; readonly stop: () => Promise; readonly request: ( request: TReq, options?: { timeoutMs?: number; recipient?: (response: TRes) => Promise; }, ) => Promise; } interface RequestResponseRuntime { readonly scope: Scope.Closeable; readonly requestor: EffectRequestResponse; } export function makeRequestResponse( options: RequestResponseOptions, ): RequestResponse { let runtime: RequestResponseRuntime | null = null; return { start: () => runtime !== null ? Promise.resolve() : Effect.runPromise( Effect.gen(function* () { const scope = yield* Scope.make(); const startRuntime = Effect.gen(function* () { const config = yield* loadMessagingRuntimeConfig(); const requestor = yield* makeEffectRequestResponseFromPubSub( PubSub.fromBackend(options.pubsub), config, { requestTopic: options.requestTopic, responseTopic: options.responseTopic, subscription: options.subscription, }, ).pipe(Scope.provide(scope)); runtime = { scope, requestor }; }); yield* startRuntime.pipe( Effect.catch((error) => Scope.close(scope, Exit.fail(error)).pipe( Effect.flatMap(() => Effect.fail(error)), ), ), ); }), ), stop: () => { const current = runtime; runtime = null; return current === null ? Promise.resolve() : Effect.runPromise(Scope.close(current.scope, Exit.void)); }, /** * Send a request and wait for responses. * * @param request - The request payload * @param options.timeoutMs - Total timeout in milliseconds (default: 300s) * @param options.recipient - Optional callback for streaming responses. * Return `true` to indicate the final response has been received. * If omitted, returns the first response. */ request: (request, requestOptions) => { const current = runtime; if (current === null) { return Effect.runPromise( Effect.fail( messagingLifecycleError( `${options.requestTopic}:${options.responseTopic}`, "request", "RequestResponse not started", ), ), ); } const timeoutMs = requestOptions?.timeoutMs ?? 300_000; const recipient = requestOptions?.recipient; return Effect.runPromise( current.requestor.request(request, { timeoutMs, ...(recipient === undefined ? {} : { recipient: (response) => Effect.tryPromise({ try: () => recipient(response), catch: (error) => messagingDeliveryError(options.responseTopic, "recipient", error), }), }), }), ); }, }; }