@nifrajs/agent-app 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Transport seam for the Agent App SDK. The client speaks only these two verbs - a request/response
3
+ * `command` and a Server-Sent-Events `stream` - so a fake, a replay driver, and a real loopback RPC
4
+ * host are interchangeable behind one interface.
5
+ *
6
+ * Caller credentials are never stored on the transport. The optional {@link AuthProvider} is invoked
7
+ * per request to mint a bearer token; the token is placed on the outgoing `Authorization` header and
8
+ * is never copied into a result, an error, or a log line.
9
+ */
10
+
11
+ import type { AgentEvent } from "@nifrajs/agent-protocol"
12
+ import { isAgentEvent } from "@nifrajs/agent-protocol"
13
+
14
+ /** One RPC-style call. `params` is JSON-serializable; `signal` cancels the in-flight request. */
15
+ export interface AgentTransportRequest {
16
+ readonly method: string
17
+ readonly params?: unknown
18
+ readonly signal?: AbortSignal
19
+ }
20
+
21
+ /**
22
+ * The outcome of a command. Non-ok responses never throw here so a caller can render a bounded state
23
+ * instead of unwinding; `error` carries only the server's status text, never a credential.
24
+ */
25
+ export type CommandOutcome<T> =
26
+ | { readonly ok: true; readonly status: number; readonly value: T }
27
+ | { readonly ok: false; readonly status: number; readonly error: string }
28
+
29
+ export interface AgentTransport {
30
+ command<T = unknown>(request: AgentTransportRequest): Promise<CommandOutcome<T>>
31
+ stream(request: AgentTransportRequest): AsyncIterable<AgentEvent>
32
+ }
33
+
34
+ /** Returns a bearer token for the next request, or `undefined` for an unauthenticated call. */
35
+ export type AuthProvider = () => string | undefined | Promise<string | undefined>
36
+
37
+ export interface HttpAgentTransportOptions {
38
+ /** RPC origin, e.g. `http://127.0.0.1:8787`. The `/rpc` path is appended. */
39
+ readonly endpoint: string
40
+ /** Per-request bearer-token source. Never stored; only the header value is used and discarded. */
41
+ readonly authorize?: AuthProvider
42
+ /** Injectable fetch for tests and non-DOM hosts. Defaults to the ambient `fetch`. */
43
+ readonly fetch?: typeof fetch
44
+ }
45
+
46
+ /** Thrown only for transport-level faults (network, malformed body). Carries no credential. */
47
+ export class AgentTransportError extends Error {
48
+ constructor(
49
+ readonly method: string,
50
+ reason: string,
51
+ ) {
52
+ super(`agent transport: ${method}: ${reason}`)
53
+ this.name = "AgentTransportError"
54
+ }
55
+ }
56
+
57
+ /** Web `fetch` + SSE transport. Browser- and Bun-compatible; depends on no Node or framework code. */
58
+ export class HttpAgentTransport implements AgentTransport {
59
+ private readonly url: string
60
+ private readonly authorize: AuthProvider | undefined
61
+ private readonly fetchImpl: typeof fetch
62
+
63
+ constructor(options: HttpAgentTransportOptions) {
64
+ if (typeof options.endpoint !== "string" || options.endpoint.length === 0)
65
+ throw new TypeError("agent transport: endpoint is required")
66
+ this.url = `${options.endpoint.replace(/\/$/, "")}/rpc`
67
+ this.authorize = options.authorize
68
+ const bound = options.fetch ?? globalThis.fetch
69
+ if (typeof bound !== "function") throw new TypeError("agent transport: no fetch available")
70
+ this.fetchImpl = bound.bind(globalThis)
71
+ }
72
+
73
+ private async headers(accept: string): Promise<Headers> {
74
+ const headers = new Headers({ "content-type": "application/json", accept })
75
+ const token = await this.authorize?.()
76
+ if (token !== undefined && token.length > 0) headers.set("authorization", `Bearer ${token}`)
77
+ return headers
78
+ }
79
+
80
+ async command<T = unknown>(request: AgentTransportRequest): Promise<CommandOutcome<T>> {
81
+ const body = serialize(request)
82
+ let response: Response
83
+ try {
84
+ response = await this.fetchImpl(this.url, {
85
+ method: "POST",
86
+ headers: await this.headers("application/json"),
87
+ body,
88
+ ...(request.signal ? { signal: request.signal } : {}),
89
+ })
90
+ } catch (error) {
91
+ throw new AgentTransportError(request.method, describe(error))
92
+ }
93
+ const text = await response.text()
94
+ if (!response.ok)
95
+ return { ok: false, status: response.status, error: text || response.statusText }
96
+ if (text.length === 0) return { ok: true, status: response.status, value: undefined as T }
97
+ try {
98
+ return { ok: true, status: response.status, value: JSON.parse(text) as T }
99
+ } catch {
100
+ throw new AgentTransportError(request.method, "response body was not JSON")
101
+ }
102
+ }
103
+
104
+ async *stream(request: AgentTransportRequest): AsyncIterable<AgentEvent> {
105
+ let response: Response
106
+ try {
107
+ response = await this.fetchImpl(this.url, {
108
+ method: "POST",
109
+ headers: await this.headers("text/event-stream"),
110
+ body: serialize(request),
111
+ ...(request.signal ? { signal: request.signal } : {}),
112
+ })
113
+ } catch (error) {
114
+ throw new AgentTransportError(request.method, describe(error))
115
+ }
116
+ if (!response.ok) {
117
+ const text = await response.text()
118
+ throw new AgentTransportError(request.method, text || `status ${response.status}`)
119
+ }
120
+ if (response.body === null) return
121
+ yield* parseEventStream(response.body, request.method)
122
+ }
123
+ }
124
+
125
+ /** Parse an SSE body into protocol events, skipping any frame whose data is not a valid event. */
126
+ export async function* parseEventStream(
127
+ body: ReadableStream<Uint8Array>,
128
+ method: string,
129
+ ): AsyncIterable<AgentEvent> {
130
+ const reader = body.getReader()
131
+ const decoder = new TextDecoder()
132
+ let buffer = ""
133
+ try {
134
+ for (;;) {
135
+ const chunk = await reader.read()
136
+ if (chunk.done) break
137
+ buffer += decoder.decode(chunk.value, { stream: true })
138
+ let boundary = buffer.indexOf("\n\n")
139
+ while (boundary !== -1) {
140
+ const frame = buffer.slice(0, boundary)
141
+ buffer = buffer.slice(boundary + 2)
142
+ const event = frameToEvent(frame)
143
+ if (event !== undefined) yield event
144
+ boundary = buffer.indexOf("\n\n")
145
+ }
146
+ }
147
+ } catch (error) {
148
+ throw new AgentTransportError(method, describe(error))
149
+ } finally {
150
+ reader.releaseLock()
151
+ }
152
+ }
153
+
154
+ function frameToEvent(frame: string): AgentEvent | undefined {
155
+ const data = frame
156
+ .split("\n")
157
+ .filter((line) => line.startsWith("data:"))
158
+ .map((line) => line.slice(5).replace(/^ /, ""))
159
+ .join("\n")
160
+ if (data.length === 0) return undefined
161
+ let value: unknown
162
+ try {
163
+ value = JSON.parse(data)
164
+ } catch {
165
+ return undefined
166
+ }
167
+ return isAgentEvent(value) ? value : undefined
168
+ }
169
+
170
+ function serialize(request: AgentTransportRequest): string {
171
+ return JSON.stringify({
172
+ method: request.method,
173
+ ...(request.params === undefined ? {} : { params: request.params }),
174
+ })
175
+ }
176
+
177
+ function describe(error: unknown): string {
178
+ return error instanceof Error ? error.message : String(error)
179
+ }