@adcp/client 2.4.0 โ 2.4.1
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/bin/adcp-async-handler.js +273 -0
- package/package.json +2 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Async webhook handler for AdCP CLI
|
|
5
|
+
*
|
|
6
|
+
* This module handles async/webhook responses by:
|
|
7
|
+
* 1. Starting a temporary HTTP server for webhooks
|
|
8
|
+
* 2. Using ngrok to expose the server publicly (if available)
|
|
9
|
+
* 3. Waiting for the async response
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const { spawn } = require('child_process');
|
|
14
|
+
const { randomUUID } = require('crypto');
|
|
15
|
+
|
|
16
|
+
class AsyncWebhookHandler {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.port = options.port || 0; // 0 = random available port
|
|
19
|
+
this.timeout = options.timeout || 300000; // 5 minutes default
|
|
20
|
+
this.debug = options.debug || false;
|
|
21
|
+
this.server = null;
|
|
22
|
+
this.ngrokProcess = null;
|
|
23
|
+
this.webhookUrl = null;
|
|
24
|
+
this.operationId = randomUUID();
|
|
25
|
+
this.responsePromise = null;
|
|
26
|
+
this.responseResolver = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if ngrok is installed
|
|
31
|
+
*/
|
|
32
|
+
static isNgrokAvailable() {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const check = spawn('which', ['ngrok']);
|
|
35
|
+
check.on('close', (code) => resolve(code === 0));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start the webhook server and optionally ngrok tunnel
|
|
41
|
+
* @param {boolean} useNgrok - Whether to use ngrok (default: true)
|
|
42
|
+
*/
|
|
43
|
+
async start(useNgrok = true) {
|
|
44
|
+
// Create the promise that will resolve when we get the webhook
|
|
45
|
+
this.responsePromise = new Promise((resolve, reject) => {
|
|
46
|
+
this.responseResolver = resolve;
|
|
47
|
+
this.responseRejector = reject;
|
|
48
|
+
|
|
49
|
+
// Set timeout
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
reject(new Error(`Webhook timeout after ${this.timeout}ms`));
|
|
52
|
+
}, this.timeout);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Start HTTP server
|
|
56
|
+
await this.startServer();
|
|
57
|
+
|
|
58
|
+
if (useNgrok) {
|
|
59
|
+
// Start ngrok tunnel
|
|
60
|
+
const ngrokAvailable = await AsyncWebhookHandler.isNgrokAvailable();
|
|
61
|
+
if (ngrokAvailable) {
|
|
62
|
+
await this.startNgrok();
|
|
63
|
+
} else {
|
|
64
|
+
throw new Error('ngrok is not installed. Install it with: brew install ngrok (Mac) or download from https://ngrok.com');
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
// Use local URL (for local agents)
|
|
68
|
+
this.webhookUrl = `http://localhost:${this.port}`;
|
|
69
|
+
|
|
70
|
+
if (this.debug) {
|
|
71
|
+
console.error(`โ
Local webhook server ready: ${this.webhookUrl}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return this.webhookUrl;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start the HTTP server
|
|
80
|
+
*/
|
|
81
|
+
startServer() {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
this.server = http.createServer((req, res) => {
|
|
84
|
+
if (req.method === 'POST') {
|
|
85
|
+
let body = '';
|
|
86
|
+
|
|
87
|
+
req.on('data', chunk => {
|
|
88
|
+
body += chunk.toString();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
req.on('end', () => {
|
|
92
|
+
try {
|
|
93
|
+
const payload = JSON.parse(body);
|
|
94
|
+
|
|
95
|
+
if (this.debug) {
|
|
96
|
+
console.error('\n๐ฃ Webhook received:');
|
|
97
|
+
console.error(JSON.stringify(payload, null, 2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Send 202 Accepted response
|
|
101
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ status: 'accepted' }));
|
|
103
|
+
|
|
104
|
+
// Resolve the promise with the webhook payload
|
|
105
|
+
if (this.responseResolver) {
|
|
106
|
+
this.responseResolver(payload);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (this.debug) {
|
|
110
|
+
console.error('Error parsing webhook:', error);
|
|
111
|
+
}
|
|
112
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
113
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
// Health check endpoint
|
|
118
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ status: 'ready', operation_id: this.operationId }));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.server.listen(this.port, () => {
|
|
124
|
+
const address = this.server.address();
|
|
125
|
+
this.port = address.port;
|
|
126
|
+
|
|
127
|
+
if (this.debug) {
|
|
128
|
+
console.error(`โ
Webhook server listening on port ${this.port}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.server.on('error', reject);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Start ngrok tunnel
|
|
140
|
+
*/
|
|
141
|
+
startNgrok() {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
if (this.debug) {
|
|
144
|
+
console.error(`๐ Starting ngrok tunnel for port ${this.port}...`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Start ngrok with JSON output for easier parsing
|
|
148
|
+
this.ngrokProcess = spawn('ngrok', ['http', String(this.port), '--log=stdout', '--log-format=json']);
|
|
149
|
+
|
|
150
|
+
let ngrokStarted = false;
|
|
151
|
+
let buffer = '';
|
|
152
|
+
|
|
153
|
+
this.ngrokProcess.stdout.on('data', (data) => {
|
|
154
|
+
buffer += data.toString();
|
|
155
|
+
const lines = buffer.split('\n');
|
|
156
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
157
|
+
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
if (!line.trim()) continue;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(line);
|
|
163
|
+
|
|
164
|
+
// Look for the tunnel URL in ngrok's JSON output
|
|
165
|
+
if (parsed.url && parsed.url.startsWith('https://')) {
|
|
166
|
+
this.webhookUrl = parsed.url;
|
|
167
|
+
ngrokStarted = true;
|
|
168
|
+
|
|
169
|
+
if (this.debug) {
|
|
170
|
+
console.error(`โ
ngrok tunnel ready: ${this.webhookUrl}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
resolve();
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// Not JSON, might be plain text output
|
|
177
|
+
// Try to extract URL from plain text
|
|
178
|
+
const urlMatch = line.match(/https:\/\/[a-z0-9-]+\.ngrok(?:-free)?\.(?:app|io)/);
|
|
179
|
+
if (urlMatch && !ngrokStarted) {
|
|
180
|
+
this.webhookUrl = urlMatch[0];
|
|
181
|
+
ngrokStarted = true;
|
|
182
|
+
|
|
183
|
+
if (this.debug) {
|
|
184
|
+
console.error(`โ
ngrok tunnel ready: ${this.webhookUrl}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
resolve();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
this.ngrokProcess.stderr.on('data', (data) => {
|
|
194
|
+
if (this.debug) {
|
|
195
|
+
console.error('ngrok stderr:', data.toString());
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
this.ngrokProcess.on('error', (error) => {
|
|
200
|
+
reject(new Error(`Failed to start ngrok: ${error.message}`));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.ngrokProcess.on('close', (code) => {
|
|
204
|
+
if (!ngrokStarted && code !== 0) {
|
|
205
|
+
reject(new Error(`ngrok exited with code ${code}`));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Timeout for ngrok startup
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
if (!ngrokStarted) {
|
|
212
|
+
reject(new Error('ngrok failed to start within 10 seconds'));
|
|
213
|
+
}
|
|
214
|
+
}, 10000);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Wait for the webhook response
|
|
220
|
+
*/
|
|
221
|
+
async waitForResponse() {
|
|
222
|
+
if (this.debug) {
|
|
223
|
+
console.error('\nโณ Waiting for async response...');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const startTime = Date.now();
|
|
227
|
+
const result = await this.responsePromise;
|
|
228
|
+
const duration = Date.now() - startTime;
|
|
229
|
+
|
|
230
|
+
if (this.debug) {
|
|
231
|
+
console.error(`โ
Response received after ${(duration / 1000).toFixed(1)}s\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Clean up resources
|
|
239
|
+
*/
|
|
240
|
+
async cleanup() {
|
|
241
|
+
if (this.debug) {
|
|
242
|
+
console.error('๐งน Cleaning up...');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Close HTTP server
|
|
246
|
+
if (this.server) {
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
this.server.close(() => resolve());
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Kill ngrok process
|
|
253
|
+
if (this.ngrokProcess) {
|
|
254
|
+
this.ngrokProcess.kill();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (this.debug) {
|
|
258
|
+
console.error('โ
Cleanup complete');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get the webhook URL with operation ID
|
|
264
|
+
*/
|
|
265
|
+
getWebhookUrl() {
|
|
266
|
+
if (!this.webhookUrl) {
|
|
267
|
+
throw new Error('Webhook server not started');
|
|
268
|
+
}
|
|
269
|
+
return `${this.webhookUrl}?operation_id=${this.operationId}`;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { AsyncWebhookHandler };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adcp/client",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "AdCP client library with protocol support for MCP and A2A, includes testing framework",
|
|
5
5
|
"main": "dist/lib/index.js",
|
|
6
6
|
"types": "dist/lib/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"dist/lib/**/*",
|
|
23
|
-
"bin
|
|
23
|
+
"bin/**/*.js",
|
|
24
24
|
"README.md",
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|