/** * Requester-side helpers for the outbound `request`/`publish` adapter tools. * * These collapse three footguns the adapters hand-rolled (and copied between * one another) into one shared, tested surface: * * 1. NATS Micro responders signal rejection via the `Nats-Service-Error-Code` * / `Nats-Service-Error` reply headers with a nil body. A blind * `decode(reply.data)` renders a hard 400 as an empty success string. * `serviceError()` surfaces it. * * 2. The prompt endpoints validate inbound bytes with `validateV2Envelope`, * so a natural plain-text body is rejected server-side. `ensureEnvelope()` * auto-wraps non-envelope bodies into a valid v2 envelope and passes a * genuine envelope through byte-untouched. * * 3. `requestEnveloped()` composes both: wrap on send, request, then turn a * service-error reply into a thrown `Error` instead of a silent empty * reply. * * See sesh-channels #63 (silent service errors) and #64 (un-enveloped bodies). */ import { ulid } from "ulid"; import { buildV2Envelope, validateV2Envelope } from "./envelope.ts"; import type { Role } from "./types.ts"; /** Canonical NATS Micro service-error header names. */ export const HEADER_SERVICE_ERROR_CODE = "Nats-Service-Error-Code"; export const HEADER_SERVICE_ERROR = "Nats-Service-Error"; /** A NATS Micro service error decoded from reply headers. */ export interface ServiceError { code: string; message: string; } /** * Minimal structural type for the readable side of a NATS headers map * (matches `MsgHdrs` from `@nats-io/nats-core` — `get` returns `""` when the * key is absent, per NATS convention — without importing the type). */ export interface ReadableHeaders { get(key: string): string; } /** * Read a NATS Micro service error from reply headers. * * Returns `{code, message}` when the responder rejected (a non-empty * `Nats-Service-Error-Code` is present), or `null` for a normal reply or when * the headers object is absent. Handles a missing headers object (request * replies without headers) gracefully. */ export function serviceError( headers: ReadableHeaders | undefined | null, ): ServiceError | null { if (!headers || typeof headers.get !== "function") return null; // NATS `get` returns "" for absent keys; treat empty/whitespace as no error. const code = (headers.get(HEADER_SERVICE_ERROR_CODE) ?? "").trim(); if (!code) return null; const message = (headers.get(HEADER_SERVICE_ERROR) ?? "").trim(); return { code, message }; } /** * Ensure an outbound body is a valid v2 envelope. * * - If `body` parses as a VALID v2 envelope, it is passed through unchanged * (returned as the original string — byte-identical on the wire). * - Otherwise (plain text, or JSON that isn't a valid envelope) the body is * wrapped as `{messageId, role, parts: [{text: body}]}` with a freshly * minted ULID messageId, and returned as serialized `Uint8Array` bytes. * * The caller (`nc.request` / `nc.publish`) accepts both `string` and * `Uint8Array`, so the union return type is sent verbatim either way. */ export function ensureEnvelope( body: string, opts: { role: Role }, ): Uint8Array | string { try { validateV2Envelope(JSON.parse(body)); return body; // already a valid envelope — pass through byte-untouched. } catch { return buildV2Envelope({ messageId: ulid(), role: opts.role, parts: [{ text: body }], }); } } /** Minimal structural view of a NATS reply message. */ export interface ReplyMsg { data: Uint8Array; headers?: ReadableHeaders; } /** Minimal structural view of the `nc.request` surface the helper needs. */ export interface RequestCapable { request( subject: string, payload: Uint8Array | string, opts?: { timeout?: number; headers?: unknown }, ): Promise; } /** Result of a successful enveloped request. */ export interface EnvelopedReply { /** The reply body decoded as UTF-8 text. */ text: string; /** The raw reply message (data + headers). */ msg: ReplyMsg; } /** Options for {@link requestEnveloped}. */ export interface RequestEnvelopedOpts { /** Role stamped on the envelope when `body` is auto-wrapped. */ role: Role; /** Reply timeout in milliseconds (forwarded to `nc.request`). */ timeout?: number; /** * Pre-built NATS headers map to send with the request. Adapters that merge * caller-supplied headers with routing headers (e.g. `reply_to`) build the * map themselves and pass it through here. */ headers?: unknown; /** * Optional outbound payload cap. When set and the enveloped payload exceeds * it, throws before dialing — matching the adapters' broker `max_payload` * guard. */ maxPayloadBytes?: number; } /** * Send a NATS request with the body coerced to a valid v2 envelope, then * surface any service error on the reply. * * A valid v2 envelope body is sent byte-untouched; anything else is wrapped * (see {@link ensureEnvelope}). On a service-error reply (per * {@link serviceError}), throws * `Error("request rejected by responder: ")`. Otherwise * returns the decoded reply text plus the raw message. */ export async function requestEnveloped( nc: RequestCapable, subject: string, body: string, opts: RequestEnvelopedOpts, ): Promise { const payload = ensureEnvelope(body, { role: opts.role }); if (opts.maxPayloadBytes !== undefined) { const byteLength = typeof payload === "string" ? new TextEncoder().encode(payload).byteLength : payload.byteLength; if (byteLength > opts.maxPayloadBytes) { throw new Error(`body exceeds broker max_payload (${opts.maxPayloadBytes} bytes)`); } } const reply = await nc.request(subject, payload, { ...(opts.timeout !== undefined ? { timeout: opts.timeout } : {}), ...(opts.headers !== undefined ? { headers: opts.headers } : {}), }); const err = serviceError(reply.headers); if (err) { throw new Error(`request rejected by responder: ${err.code} ${err.message}`.trim()); } return { text: new TextDecoder().decode(reply.data), msg: reply }; }