@nifrajs/agent-app 3.3.0 → 3.5.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.
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/transport.d.ts +5 -2
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +117 -12
- package/dist/transport.js.map +1 -1
- package/dist/view-models.d.ts +58 -0
- package/dist/view-models.d.ts.map +1 -1
- package/dist/view-models.js +442 -1
- package/dist/view-models.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +1 -0
- package/src/index.ts +6 -0
- package/src/transport.ts +125 -12
- package/src/view-models.ts +563 -0
package/src/transport.ts
CHANGED
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
* is never copied into a result, an error, or a log line.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
type AgentEvent,
|
|
13
|
+
assertTransportByteLimit,
|
|
14
|
+
isAgentEvent,
|
|
15
|
+
MAX_TRANSPORT_BYTES,
|
|
16
|
+
parseTransportContentLength,
|
|
17
|
+
readBoundedTransportBytes,
|
|
18
|
+
} from "@nifrajs/agent-protocol"
|
|
13
19
|
|
|
14
20
|
/** One RPC-style call. `params` is JSON-serializable; `signal` cancels the in-flight request. */
|
|
15
21
|
export interface AgentTransportRequest {
|
|
@@ -41,6 +47,8 @@ export interface HttpAgentTransportOptions {
|
|
|
41
47
|
readonly authorize?: AuthProvider
|
|
42
48
|
/** Injectable fetch for tests and non-DOM hosts. Defaults to the ambient `fetch`. */
|
|
43
49
|
readonly fetch?: typeof fetch
|
|
50
|
+
/** Maximum response bytes retained by command and SSE reads. Defaults to 8 MiB. */
|
|
51
|
+
readonly maxResponseBytes?: number
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
/** Thrown only for transport-level faults (network, malformed body). Carries no credential. */
|
|
@@ -59,6 +67,7 @@ export class HttpAgentTransport implements AgentTransport {
|
|
|
59
67
|
private readonly url: string
|
|
60
68
|
private readonly authorize: AuthProvider | undefined
|
|
61
69
|
private readonly fetchImpl: typeof fetch
|
|
70
|
+
private readonly maxResponseBytes: number
|
|
62
71
|
|
|
63
72
|
constructor(options: HttpAgentTransportOptions) {
|
|
64
73
|
if (typeof options.endpoint !== "string" || options.endpoint.length === 0)
|
|
@@ -68,6 +77,8 @@ export class HttpAgentTransport implements AgentTransport {
|
|
|
68
77
|
const bound = options.fetch ?? globalThis.fetch
|
|
69
78
|
if (typeof bound !== "function") throw new TypeError("agent transport: no fetch available")
|
|
70
79
|
this.fetchImpl = bound.bind(globalThis)
|
|
80
|
+
this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
|
|
81
|
+
assertResponseLimit(this.maxResponseBytes)
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
private async headers(accept: string): Promise<Headers> {
|
|
@@ -90,7 +101,7 @@ export class HttpAgentTransport implements AgentTransport {
|
|
|
90
101
|
} catch (error) {
|
|
91
102
|
throw new AgentTransportError(request.method, describe(error))
|
|
92
103
|
}
|
|
93
|
-
const text = await response.
|
|
104
|
+
const text = await readResponseText(response, request.method, this.maxResponseBytes)
|
|
94
105
|
if (!response.ok)
|
|
95
106
|
return { ok: false, status: response.status, error: text || response.statusText }
|
|
96
107
|
if (text.length === 0) return { ok: true, status: response.status, value: undefined as T }
|
|
@@ -114,11 +125,11 @@ export class HttpAgentTransport implements AgentTransport {
|
|
|
114
125
|
throw new AgentTransportError(request.method, describe(error))
|
|
115
126
|
}
|
|
116
127
|
if (!response.ok) {
|
|
117
|
-
const text = await response.
|
|
128
|
+
const text = await readResponseText(response, request.method, this.maxResponseBytes)
|
|
118
129
|
throw new AgentTransportError(request.method, text || `status ${response.status}`)
|
|
119
130
|
}
|
|
120
131
|
if (response.body === null) return
|
|
121
|
-
yield* parseEventStream(response.body, request.method)
|
|
132
|
+
yield* parseEventStream(response.body, request.method, this.maxResponseBytes)
|
|
122
133
|
}
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -126,34 +137,76 @@ export class HttpAgentTransport implements AgentTransport {
|
|
|
126
137
|
export async function* parseEventStream(
|
|
127
138
|
body: ReadableStream<Uint8Array>,
|
|
128
139
|
method: string,
|
|
140
|
+
maxFrameBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
|
129
141
|
): AsyncIterable<AgentEvent> {
|
|
142
|
+
assertResponseLimit(maxFrameBytes)
|
|
130
143
|
const reader = body.getReader()
|
|
131
144
|
const decoder = new TextDecoder()
|
|
145
|
+
const encoder = new TextEncoder()
|
|
132
146
|
let buffer = ""
|
|
147
|
+
let reachedEof = false
|
|
133
148
|
try {
|
|
134
149
|
for (;;) {
|
|
135
150
|
const chunk = await reader.read()
|
|
136
|
-
if (chunk.done)
|
|
151
|
+
if (chunk.done) {
|
|
152
|
+
reachedEof = true
|
|
153
|
+
buffer += decoder.decode()
|
|
154
|
+
let boundary = sseBoundary(buffer)
|
|
155
|
+
while (boundary !== undefined) {
|
|
156
|
+
const frame = buffer.slice(0, boundary.index)
|
|
157
|
+
buffer = buffer.slice(boundary.end)
|
|
158
|
+
if (encoder.encode(frame).byteLength > maxFrameBytes)
|
|
159
|
+
throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
|
|
160
|
+
const event = frameToEvent(frame)
|
|
161
|
+
if (event !== undefined) yield event
|
|
162
|
+
boundary = sseBoundary(buffer)
|
|
163
|
+
}
|
|
164
|
+
if (encoder.encode(buffer).byteLength > maxFrameBytes)
|
|
165
|
+
throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
|
|
166
|
+
const event = frameToEvent(buffer)
|
|
167
|
+
if (event !== undefined) yield event
|
|
168
|
+
break
|
|
169
|
+
}
|
|
137
170
|
buffer += decoder.decode(chunk.value, { stream: true })
|
|
138
|
-
let boundary = buffer
|
|
139
|
-
while (boundary !==
|
|
140
|
-
const frame = buffer.slice(0, boundary)
|
|
141
|
-
buffer = buffer.slice(boundary
|
|
171
|
+
let boundary = sseBoundary(buffer)
|
|
172
|
+
while (boundary !== undefined) {
|
|
173
|
+
const frame = buffer.slice(0, boundary.index)
|
|
174
|
+
buffer = buffer.slice(boundary.end)
|
|
175
|
+
if (encoder.encode(frame).byteLength > maxFrameBytes)
|
|
176
|
+
throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
|
|
142
177
|
const event = frameToEvent(frame)
|
|
143
178
|
if (event !== undefined) yield event
|
|
144
|
-
boundary = buffer
|
|
179
|
+
boundary = sseBoundary(buffer)
|
|
180
|
+
}
|
|
181
|
+
if (encoder.encode(buffer).byteLength > maxFrameBytes) {
|
|
182
|
+
try {
|
|
183
|
+
await reader.cancel()
|
|
184
|
+
} catch {
|
|
185
|
+
// Preserve the bounded transport error if the source has already closed.
|
|
186
|
+
}
|
|
187
|
+
throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
|
|
145
188
|
}
|
|
146
189
|
}
|
|
147
190
|
} catch (error) {
|
|
191
|
+
if (error instanceof AgentTransportError) throw error
|
|
148
192
|
throw new AgentTransportError(method, describe(error))
|
|
149
193
|
} finally {
|
|
194
|
+
if (!reachedEof) {
|
|
195
|
+
// Releasing a reader lock does not stop the response producer. Cancel the body when a
|
|
196
|
+
// consumer breaks early so sockets, server-side work, and buffered chunks can be reclaimed.
|
|
197
|
+
try {
|
|
198
|
+
await reader.cancel()
|
|
199
|
+
} catch {
|
|
200
|
+
// Preserve the original transport/read error, or the consumer's early-return semantics.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
150
203
|
reader.releaseLock()
|
|
151
204
|
}
|
|
152
205
|
}
|
|
153
206
|
|
|
154
207
|
function frameToEvent(frame: string): AgentEvent | undefined {
|
|
155
208
|
const data = frame
|
|
156
|
-
.split(
|
|
209
|
+
.split(/\r\n|\n|\r/)
|
|
157
210
|
.filter((line) => line.startsWith("data:"))
|
|
158
211
|
.map((line) => line.slice(5).replace(/^ /, ""))
|
|
159
212
|
.join("\n")
|
|
@@ -167,6 +220,66 @@ function frameToEvent(frame: string): AgentEvent | undefined {
|
|
|
167
220
|
return isAgentEvent(value) ? value : undefined
|
|
168
221
|
}
|
|
169
222
|
|
|
223
|
+
function sseBoundary(value: string): { readonly index: number; readonly end: number } | undefined {
|
|
224
|
+
for (let index = 0; index < value.length; index++) {
|
|
225
|
+
const firstLength = lineEndingLength(value, index)
|
|
226
|
+
if (firstLength === undefined) return undefined
|
|
227
|
+
if (firstLength === 0) continue
|
|
228
|
+
const secondIndex = index + firstLength
|
|
229
|
+
const secondLength = lineEndingLength(value, secondIndex)
|
|
230
|
+
if (secondLength === undefined) return undefined
|
|
231
|
+
if (secondLength > 0) return { index, end: secondIndex + secondLength }
|
|
232
|
+
index = secondIndex
|
|
233
|
+
}
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function lineEndingLength(value: string, index: number): number | undefined {
|
|
238
|
+
const character = value[index]
|
|
239
|
+
if (character === "\n") return 1
|
|
240
|
+
if (character !== "\r") return 0
|
|
241
|
+
if (index + 1 >= value.length) return undefined
|
|
242
|
+
return value[index + 1] === "\n" ? 2 : 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
246
|
+
const MAX_RESPONSE_BYTES = MAX_TRANSPORT_BYTES
|
|
247
|
+
|
|
248
|
+
function assertResponseLimit(value: number): void {
|
|
249
|
+
assertTransportByteLimit(value, {
|
|
250
|
+
minimum: 1_024,
|
|
251
|
+
maximum: MAX_RESPONSE_BYTES,
|
|
252
|
+
label: "agent transport: maxResponseBytes",
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readResponseText(
|
|
257
|
+
response: Response,
|
|
258
|
+
method: string,
|
|
259
|
+
maxBytes: number,
|
|
260
|
+
): Promise<string> {
|
|
261
|
+
const declared = parseTransportContentLength(response.headers.get("content-length"))
|
|
262
|
+
if (declared !== undefined && declared > maxBytes) {
|
|
263
|
+
try {
|
|
264
|
+
await response.body?.cancel()
|
|
265
|
+
} catch {
|
|
266
|
+
// The response is already over its cap; cancellation failure must not replace the useful error.
|
|
267
|
+
}
|
|
268
|
+
throw new AgentTransportError(method, "response body exceeds the configured limit")
|
|
269
|
+
}
|
|
270
|
+
if (response.body === null) return ""
|
|
271
|
+
const read = await readBoundedTransportBytes(response.body, maxBytes)
|
|
272
|
+
if (!read.ok) {
|
|
273
|
+
throw new AgentTransportError(
|
|
274
|
+
method,
|
|
275
|
+
read.reason === "too-large"
|
|
276
|
+
? "response body exceeds the configured limit"
|
|
277
|
+
: describe(read.error),
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
return new TextDecoder().decode(read.bytes)
|
|
281
|
+
}
|
|
282
|
+
|
|
170
283
|
function serialize(request: AgentTransportRequest): string {
|
|
171
284
|
return JSON.stringify({
|
|
172
285
|
method: request.method,
|