@nextclaw/ncp-http-agent-server 0.1.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/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +431 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NextClaw contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @nextclaw/ncp-http-agent-server
|
|
2
|
+
|
|
3
|
+
HTTP/SSE transport adapter for exposing an `NcpAgentClientEndpoint` over Hono routes.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm -C packages/nextclaw-ncp-http-agent-server build
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
- `createNcpHttpAgentRouter(options)` — options require `agentClientEndpoint: NcpAgentClientEndpoint`
|
|
14
|
+
- `mountNcpHttpAgentRoutes(app, options)`
|
|
15
|
+
|
|
16
|
+
**Options:**
|
|
17
|
+
- `agentClientEndpoint` — client endpoint to forward requests to (in-process adapter or remote HTTP client)
|
|
18
|
+
- `replayProvider` (optional) — When set, `/reconnect` replays stored events instead of forwarding to the agent. Scenario: user reconnects after network drop and wants to continue watching the previous reply; with replayProvider we replay from persistence, without it we forward to the agent.
|
|
19
|
+
- `basePath`, `requestTimeoutMs` — path and timeout
|
|
20
|
+
|
|
21
|
+
For in-process agent (`NcpAgentServerEndpoint`), use `createAgentClientFromServer` from `@nextclaw/ncp` to wrap it.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NcpResumeRequestPayload, NcpEndpointEvent, NcpAgentClientEndpoint } from '@nextclaw/ncp';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Replays stored session events for `/reconnect`.
|
|
6
|
+
*
|
|
7
|
+
* **Scenario**: User sends a message, agent streams SSE back. Network drops mid-stream.
|
|
8
|
+
* User reconnects and requests `GET /reconnect?sessionId=xxx&remoteRunId=yyy` to
|
|
9
|
+
* "continue watching the previous reply".
|
|
10
|
+
*
|
|
11
|
+
* **Two paths**:
|
|
12
|
+
* - **Replay** (with replayProvider): Do not call agent. Fetch that run's events
|
|
13
|
+
* (message.accepted, message.text-delta, message.completed, etc.) from persistence
|
|
14
|
+
* and stream them in order. Use when you have session/event storage and want to
|
|
15
|
+
* avoid re-running the agent.
|
|
16
|
+
* - **Forward** (no replayProvider): Forward `message.resume-request` to the agent
|
|
17
|
+
* and let it recover or re-run.
|
|
18
|
+
*
|
|
19
|
+
* **Implementation**: `stream` fetches events by payload.sessionId and payload.remoteRunId
|
|
20
|
+
* from your storage and yields them in order.
|
|
21
|
+
*/
|
|
22
|
+
type NcpHttpAgentReplayProvider = {
|
|
23
|
+
stream(params: {
|
|
24
|
+
payload: NcpResumeRequestPayload;
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
}): AsyncIterable<NcpEndpointEvent>;
|
|
27
|
+
};
|
|
28
|
+
type NcpHttpAgentServerOptions = {
|
|
29
|
+
/** Client endpoint to forward requests to (in-process adapter or remote HTTP client). */
|
|
30
|
+
agentClientEndpoint: NcpAgentClientEndpoint;
|
|
31
|
+
basePath?: string;
|
|
32
|
+
requestTimeoutMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Optional. When set, `/reconnect` replays stored events instead of forwarding to the agent.
|
|
35
|
+
* When not set, forwards `message.resume-request` to the agent.
|
|
36
|
+
*/
|
|
37
|
+
replayProvider?: NcpHttpAgentReplayProvider;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Framework-agnostic HTTP handler interface for NCP agent routes. */
|
|
41
|
+
interface NcpHttpAgentHandler {
|
|
42
|
+
handleSend(request: Request): Promise<Response>;
|
|
43
|
+
handleReconnect(request: Request): Promise<Response>;
|
|
44
|
+
handleAbort(request: Request): Promise<Response>;
|
|
45
|
+
}
|
|
46
|
+
type NcpHttpAgentHandlerOptions = {
|
|
47
|
+
agentClientEndpoint: NcpAgentClientEndpoint;
|
|
48
|
+
/**
|
|
49
|
+
* Optional. When set, `/reconnect` replays stored events instead of forwarding to the agent.
|
|
50
|
+
* See NcpHttpAgentReplayProvider for details.
|
|
51
|
+
*/
|
|
52
|
+
replayProvider?: NcpHttpAgentReplayProvider;
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Framework-agnostic controller for NCP agent HTTP routes.
|
|
58
|
+
* Forwards /send and /reconnect to agentClientEndpoint; /reconnect uses replayProvider when set.
|
|
59
|
+
*/
|
|
60
|
+
declare class NcpHttpAgentController implements NcpHttpAgentHandler {
|
|
61
|
+
private readonly options;
|
|
62
|
+
constructor(options: NcpHttpAgentHandlerOptions);
|
|
63
|
+
handleSend(request: Request): Promise<Response>;
|
|
64
|
+
handleReconnect(request: Request): Promise<Response>;
|
|
65
|
+
handleAbort(request: Request): Promise<Response>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
declare function createNcpHttpAgentRouter(options: NcpHttpAgentServerOptions): Hono;
|
|
69
|
+
declare function mountNcpHttpAgentRoutes(app: Hono, options: NcpHttpAgentServerOptions): void;
|
|
70
|
+
|
|
71
|
+
export { NcpHttpAgentController, type NcpHttpAgentHandler, type NcpHttpAgentHandlerOptions, type NcpHttpAgentReplayProvider, type NcpHttpAgentServerOptions, createNcpHttpAgentRouter, mountNcpHttpAgentRoutes };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var DEFAULT_BASE_PATH = "/ncp/agent";
|
|
3
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
4
|
+
|
|
5
|
+
// src/parsers.ts
|
|
6
|
+
function normalizeBasePath(basePath) {
|
|
7
|
+
const raw = (basePath ?? DEFAULT_BASE_PATH).trim();
|
|
8
|
+
if (!raw) {
|
|
9
|
+
return DEFAULT_BASE_PATH;
|
|
10
|
+
}
|
|
11
|
+
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
12
|
+
return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
|
|
13
|
+
}
|
|
14
|
+
function sanitizeTimeout(timeoutMs) {
|
|
15
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
|
|
16
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
17
|
+
}
|
|
18
|
+
return Math.max(1e3, Math.trunc(timeoutMs));
|
|
19
|
+
}
|
|
20
|
+
async function parseRequestEnvelope(request) {
|
|
21
|
+
try {
|
|
22
|
+
const payload = await request.json();
|
|
23
|
+
if (!isRecord(payload)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof payload.sessionId !== "string" || !payload.sessionId.trim()) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!isRecord(payload.message)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return payload;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseResumePayloadFromUrl(url) {
|
|
38
|
+
const query = new URL(url).searchParams;
|
|
39
|
+
const sessionId = query.get("sessionId")?.trim();
|
|
40
|
+
const remoteRunId = query.get("remoteRunId")?.trim();
|
|
41
|
+
if (!sessionId || !remoteRunId) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const fromEventIndexRaw = query.get("fromEventIndex");
|
|
45
|
+
const fromEventIndex = typeof fromEventIndexRaw === "string" && Number.isFinite(Number.parseInt(fromEventIndexRaw, 10)) ? Math.max(0, Number.parseInt(fromEventIndexRaw, 10)) : void 0;
|
|
46
|
+
return {
|
|
47
|
+
sessionId,
|
|
48
|
+
remoteRunId,
|
|
49
|
+
...typeof fromEventIndex === "number" ? { fromEventIndex } : {}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function parseAbortPayload(request) {
|
|
53
|
+
try {
|
|
54
|
+
const payload = await request.json();
|
|
55
|
+
if (!isRecord(payload)) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
const messageId = readTrimmedString(payload.messageId);
|
|
59
|
+
const correlationId = readTrimmedString(payload.correlationId);
|
|
60
|
+
const runId = readTrimmedString(payload.runId);
|
|
61
|
+
return {
|
|
62
|
+
...messageId ? { messageId } : {},
|
|
63
|
+
...correlationId ? { correlationId } : {},
|
|
64
|
+
...runId ? { runId } : {}
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function readTrimmedString(value) {
|
|
71
|
+
return typeof value === "string" ? value.trim() : "";
|
|
72
|
+
}
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/scope.ts
|
|
78
|
+
function extractScopeFromEvent(event) {
|
|
79
|
+
const payload = readPayload(event);
|
|
80
|
+
if (!payload) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
const runId = readString(payload.runId) ?? readString(payload.remoteRunId);
|
|
84
|
+
return {
|
|
85
|
+
sessionId: readString(payload.sessionId),
|
|
86
|
+
correlationId: readString(payload.correlationId),
|
|
87
|
+
runId
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function matchesScope(scope, event) {
|
|
91
|
+
const eventScope = extractScopeFromEvent(event);
|
|
92
|
+
if (eventScope.sessionId && eventScope.sessionId !== scope.sessionId) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (scope.correlationId && eventScope.correlationId && eventScope.correlationId !== scope.correlationId) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (scope.runId && eventScope.runId && eventScope.runId !== scope.runId) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
function isTerminalEvent(event) {
|
|
104
|
+
switch (event.type) {
|
|
105
|
+
case "message.completed":
|
|
106
|
+
case "message.failed":
|
|
107
|
+
case "run.finished":
|
|
108
|
+
case "run.error":
|
|
109
|
+
case "message.abort":
|
|
110
|
+
return true;
|
|
111
|
+
default:
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function readPayload(event) {
|
|
116
|
+
if (!("payload" in event)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return isRecord2(event.payload) ? event.payload : null;
|
|
120
|
+
}
|
|
121
|
+
function readString(value) {
|
|
122
|
+
return typeof value === "string" && value ? value : void 0;
|
|
123
|
+
}
|
|
124
|
+
function isRecord2(value) {
|
|
125
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/sse-stream.ts
|
|
129
|
+
function createSseEventStream(events, signal) {
|
|
130
|
+
const encoder = new TextEncoder();
|
|
131
|
+
return new ReadableStream({
|
|
132
|
+
start: async (controller) => {
|
|
133
|
+
let closed = false;
|
|
134
|
+
const close = () => {
|
|
135
|
+
if (closed) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
closed = true;
|
|
139
|
+
controller.close();
|
|
140
|
+
};
|
|
141
|
+
const onAbort = () => {
|
|
142
|
+
close();
|
|
143
|
+
};
|
|
144
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
145
|
+
try {
|
|
146
|
+
for await (const event of events) {
|
|
147
|
+
if (closed || signal?.aborted) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
controller.enqueue(encoder.encode(toSseFrame(event.event, event.data)));
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
signal?.removeEventListener("abort", onAbort);
|
|
154
|
+
close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function buildSseResponse(stream) {
|
|
160
|
+
return new Response(stream, {
|
|
161
|
+
status: 200,
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
164
|
+
"Cache-Control": "no-cache, no-transform",
|
|
165
|
+
Connection: "keep-alive",
|
|
166
|
+
"X-Accel-Buffering": "no"
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function toSseFrame(eventName, data) {
|
|
171
|
+
return `event: ${eventName}
|
|
172
|
+
data: ${JSON.stringify(data)}
|
|
173
|
+
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
function toNcpEventFrame(event) {
|
|
177
|
+
return {
|
|
178
|
+
event: "ncp-event",
|
|
179
|
+
data: event
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function toErrorFrame(code, message) {
|
|
183
|
+
return {
|
|
184
|
+
event: "error",
|
|
185
|
+
data: {
|
|
186
|
+
code,
|
|
187
|
+
message
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/async-queue.ts
|
|
193
|
+
function createAsyncQueue() {
|
|
194
|
+
const items = [];
|
|
195
|
+
let closed = false;
|
|
196
|
+
let pendingResolve = null;
|
|
197
|
+
const iterable = {
|
|
198
|
+
[Symbol.asyncIterator]() {
|
|
199
|
+
return {
|
|
200
|
+
next: () => {
|
|
201
|
+
if (items.length > 0) {
|
|
202
|
+
return Promise.resolve({
|
|
203
|
+
value: items.shift(),
|
|
204
|
+
done: false
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (closed) {
|
|
208
|
+
return Promise.resolve({
|
|
209
|
+
value: void 0,
|
|
210
|
+
done: true
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
pendingResolve = resolve;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
push(item) {
|
|
222
|
+
if (closed) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (pendingResolve) {
|
|
226
|
+
const resolve = pendingResolve;
|
|
227
|
+
pendingResolve = null;
|
|
228
|
+
resolve({ value: item, done: false });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
items.push(item);
|
|
232
|
+
},
|
|
233
|
+
close() {
|
|
234
|
+
if (closed) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
closed = true;
|
|
238
|
+
if (pendingResolve) {
|
|
239
|
+
const resolve = pendingResolve;
|
|
240
|
+
pendingResolve = null;
|
|
241
|
+
resolve({
|
|
242
|
+
value: void 0,
|
|
243
|
+
done: true
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
iterable
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/utils.ts
|
|
252
|
+
function errorMessage(error) {
|
|
253
|
+
if (error instanceof Error && error.message) {
|
|
254
|
+
return error.message;
|
|
255
|
+
}
|
|
256
|
+
return String(error ?? "Unknown error");
|
|
257
|
+
}
|
|
258
|
+
function jsonResponse(body, status = 200) {
|
|
259
|
+
return new Response(JSON.stringify(body), {
|
|
260
|
+
status,
|
|
261
|
+
headers: { "Content-Type": "application/json" }
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/stream-handlers.ts
|
|
266
|
+
function createForwardResponse(options) {
|
|
267
|
+
const { requestSignal } = options;
|
|
268
|
+
return buildSseResponse(
|
|
269
|
+
createSseEventStream(createForwardSseEvents(options), requestSignal)
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
async function* createForwardSseEvents(options) {
|
|
273
|
+
const { endpoint, requestEvent, requestSignal, timeoutMs, scope } = options;
|
|
274
|
+
const queue = createAsyncQueue();
|
|
275
|
+
let timeoutId = null;
|
|
276
|
+
let unsubscribe = null;
|
|
277
|
+
let stopped = false;
|
|
278
|
+
const push = (frame) => {
|
|
279
|
+
if (!stopped) {
|
|
280
|
+
queue.push(frame);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const stop = () => {
|
|
284
|
+
if (stopped) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
stopped = true;
|
|
288
|
+
if (timeoutId) {
|
|
289
|
+
clearTimeout(timeoutId);
|
|
290
|
+
timeoutId = null;
|
|
291
|
+
}
|
|
292
|
+
if (unsubscribe) {
|
|
293
|
+
unsubscribe();
|
|
294
|
+
unsubscribe = null;
|
|
295
|
+
}
|
|
296
|
+
requestSignal.removeEventListener("abort", stop);
|
|
297
|
+
queue.close();
|
|
298
|
+
};
|
|
299
|
+
requestSignal.addEventListener("abort", stop, { once: true });
|
|
300
|
+
timeoutId = setTimeout(() => {
|
|
301
|
+
push(toErrorFrame("TIMEOUT", "NCP HTTP stream timed out before terminal event."));
|
|
302
|
+
stop();
|
|
303
|
+
}, timeoutMs);
|
|
304
|
+
unsubscribe = endpoint.subscribe((event) => {
|
|
305
|
+
if (!matchesScope(scope, event)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
push(toNcpEventFrame(event));
|
|
309
|
+
if (isTerminalEvent(event)) {
|
|
310
|
+
stop();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
void endpoint.emit(requestEvent).catch((error) => {
|
|
314
|
+
push(toErrorFrame("EMIT_FAILED", errorMessage(error)));
|
|
315
|
+
stop();
|
|
316
|
+
});
|
|
317
|
+
try {
|
|
318
|
+
for await (const frame of queue.iterable) {
|
|
319
|
+
yield frame;
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
stop();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function createReplayResponse(options) {
|
|
326
|
+
const { signal } = options;
|
|
327
|
+
return buildSseResponse(
|
|
328
|
+
createSseEventStream(createReplaySseEvents(options), signal)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
async function* createReplaySseEvents(options) {
|
|
332
|
+
const { replayProvider, payload, signal } = options;
|
|
333
|
+
try {
|
|
334
|
+
for await (const event of replayProvider.stream({ payload, signal })) {
|
|
335
|
+
if (signal.aborted) {
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
yield toNcpEventFrame(event);
|
|
339
|
+
if (isTerminalEvent(event)) {
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
yield toErrorFrame("REPLAY_FAILED", errorMessage(error));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/controller.ts
|
|
349
|
+
var NcpHttpAgentController = class {
|
|
350
|
+
constructor(options) {
|
|
351
|
+
this.options = options;
|
|
352
|
+
}
|
|
353
|
+
async handleSend(request) {
|
|
354
|
+
const { agentClientEndpoint, timeoutMs } = this.options;
|
|
355
|
+
const envelope = await parseRequestEnvelope(request);
|
|
356
|
+
if (!envelope) {
|
|
357
|
+
return jsonResponse(
|
|
358
|
+
{ ok: false, error: { code: "INVALID_BODY", message: "Invalid NCP request envelope." } },
|
|
359
|
+
400
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return createForwardResponse({
|
|
363
|
+
endpoint: agentClientEndpoint,
|
|
364
|
+
requestEvent: { type: "message.request", payload: envelope },
|
|
365
|
+
requestSignal: request.signal,
|
|
366
|
+
timeoutMs,
|
|
367
|
+
scope: {
|
|
368
|
+
sessionId: envelope.sessionId,
|
|
369
|
+
correlationId: envelope.correlationId
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async handleReconnect(request) {
|
|
374
|
+
const { agentClientEndpoint, replayProvider, timeoutMs } = this.options;
|
|
375
|
+
const resumePayload = parseResumePayloadFromUrl(request.url);
|
|
376
|
+
if (!resumePayload) {
|
|
377
|
+
return jsonResponse(
|
|
378
|
+
{ ok: false, error: { code: "INVALID_QUERY", message: "sessionId and remoteRunId are required." } },
|
|
379
|
+
400
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (replayProvider) {
|
|
383
|
+
return createReplayResponse({
|
|
384
|
+
replayProvider,
|
|
385
|
+
payload: resumePayload,
|
|
386
|
+
signal: request.signal
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return createForwardResponse({
|
|
390
|
+
endpoint: agentClientEndpoint,
|
|
391
|
+
requestEvent: { type: "message.resume-request", payload: resumePayload },
|
|
392
|
+
requestSignal: request.signal,
|
|
393
|
+
timeoutMs,
|
|
394
|
+
scope: {
|
|
395
|
+
sessionId: resumePayload.sessionId,
|
|
396
|
+
runId: resumePayload.remoteRunId
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async handleAbort(request) {
|
|
401
|
+
const { agentClientEndpoint } = this.options;
|
|
402
|
+
const payload = await parseAbortPayload(request);
|
|
403
|
+
await agentClientEndpoint.abort(payload);
|
|
404
|
+
return jsonResponse({ ok: true });
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// src/router.ts
|
|
409
|
+
import { Hono } from "hono";
|
|
410
|
+
function createNcpHttpAgentRouter(options) {
|
|
411
|
+
const app = new Hono();
|
|
412
|
+
mountNcpHttpAgentRoutes(app, options);
|
|
413
|
+
return app;
|
|
414
|
+
}
|
|
415
|
+
function mountNcpHttpAgentRoutes(app, options) {
|
|
416
|
+
const { basePath: rawBasePath, agentClientEndpoint, replayProvider, requestTimeoutMs } = options;
|
|
417
|
+
const basePath = normalizeBasePath(rawBasePath);
|
|
418
|
+
const controller = new NcpHttpAgentController({
|
|
419
|
+
agentClientEndpoint,
|
|
420
|
+
replayProvider,
|
|
421
|
+
timeoutMs: sanitizeTimeout(requestTimeoutMs)
|
|
422
|
+
});
|
|
423
|
+
app.post(`${basePath}/send`, (c) => controller.handleSend(c.req.raw));
|
|
424
|
+
app.get(`${basePath}/reconnect`, (c) => controller.handleReconnect(c.req.raw));
|
|
425
|
+
app.post(`${basePath}/abort`, (c) => controller.handleAbort(c.req.raw));
|
|
426
|
+
}
|
|
427
|
+
export {
|
|
428
|
+
NcpHttpAgentController,
|
|
429
|
+
createNcpHttpAgentRouter,
|
|
430
|
+
mountNcpHttpAgentRoutes
|
|
431
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nextclaw/ncp-http-agent-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "HTTP/SSE server transport adapter for NCP agent endpoints.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"development": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"hono": "^4.9.8",
|
|
19
|
+
"@nextclaw/ncp": "0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.17.6",
|
|
23
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
24
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
25
|
+
"eslint": "^8.57.1",
|
|
26
|
+
"eslint-config-prettier": "^9.1.0",
|
|
27
|
+
"prettier": "^3.3.3",
|
|
28
|
+
"tsup": "^8.3.5",
|
|
29
|
+
"typescript": "^5.6.3",
|
|
30
|
+
"vitest": "^2.1.2"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup src/index.ts --format esm --dts --out-dir dist",
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"tsc": "tsc -p tsconfig.json",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
}
|
|
38
|
+
}
|