@elizaos/plugin-ngrok 2.0.0-beta.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/README.md +325 -0
- package/dist/__tests__/NgrokTestSuite.d.ts +6 -0
- package/dist/__tests__/NgrokTestSuite.d.ts.map +1 -0
- package/dist/__tests__/NgrokTestSuite.js +92 -0
- package/dist/__tests__/NgrokTestSuite.js.map +1 -0
- package/dist/actions/get-tunnel-status.d.ts +4 -0
- package/dist/actions/get-tunnel-status.d.ts.map +1 -0
- package/dist/actions/get-tunnel-status.js +186 -0
- package/dist/actions/get-tunnel-status.js.map +1 -0
- package/dist/actions/start-tunnel.d.ts +4 -0
- package/dist/actions/start-tunnel.d.ts.map +1 -0
- package/dist/actions/start-tunnel.js +221 -0
- package/dist/actions/start-tunnel.js.map +1 -0
- package/dist/actions/stop-tunnel.d.ts +4 -0
- package/dist/actions/stop-tunnel.d.ts.map +1 -0
- package/dist/actions/stop-tunnel.js +174 -0
- package/dist/actions/stop-tunnel.js.map +1 -0
- package/dist/environment.d.ts +12 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +68 -0
- package/dist/environment.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/services/NgrokService.d.ts +30 -0
- package/dist/services/NgrokService.d.ts.map +1 -0
- package/dist/services/NgrokService.js +333 -0
- package/dist/services/NgrokService.js.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/NgrokTestSuite.ts +110 -0
- package/src/__tests__/debug-mock.test.ts +15 -0
- package/src/__tests__/e2e/real-ngrok.test.ts +543 -0
- package/src/__tests__/integration/webhook-scenarios.test.ts +463 -0
- package/src/__tests__/mocks/NgrokServiceMock.ts +76 -0
- package/src/__tests__/ngrok-integration.test.ts +521 -0
- package/src/__tests__/test-config.ts +83 -0
- package/src/__tests__/test-helpers.ts +43 -0
- package/src/__tests__/test-setup.ts +174 -0
- package/src/__tests__/test-utils.ts +155 -0
- package/src/__tests__/unit/actions.test.ts +402 -0
- package/src/__tests__/unit/environment.test.ts +352 -0
- package/src/actions/get-tunnel-status.ts +218 -0
- package/src/actions/start-tunnel.ts +255 -0
- package/src/actions/stop-tunnel.ts +203 -0
- package/src/environment.ts +75 -0
- package/src/index.ts +33 -0
- package/src/services/NgrokService.ts +401 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { elizaLogger, type IAgentRuntime, Service } from '@elizaos/core';
|
|
2
|
+
import type { ITunnelService, TunnelStatus } from '@elizaos/plugin-tunnel';
|
|
3
|
+
|
|
4
|
+
interface TunnelConfig {
|
|
5
|
+
provider: 'ngrok';
|
|
6
|
+
authToken?: string;
|
|
7
|
+
region?: string;
|
|
8
|
+
subdomain?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface NgrokApiTunnel {
|
|
12
|
+
proto?: string;
|
|
13
|
+
public_url?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface NgrokApiResponse {
|
|
17
|
+
tunnels?: NgrokApiTunnel[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Coerce runtime.getSetting() (string | number | boolean | null) to string. */
|
|
21
|
+
function settingString(value: string | number | boolean | null | undefined): string | undefined {
|
|
22
|
+
if (value === null || value === undefined) return undefined;
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function errorMessage(error: unknown): string {
|
|
27
|
+
return error instanceof Error ? error.message : String(error);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
31
|
+
import * as http from 'node:http';
|
|
32
|
+
import { validateNgrokConfig } from '../environment';
|
|
33
|
+
|
|
34
|
+
export class NgrokService extends Service implements ITunnelService {
|
|
35
|
+
static serviceType = 'tunnel';
|
|
36
|
+
readonly capabilityDescription =
|
|
37
|
+
'Provides secure tunnel functionality using ngrok for exposing local services to the internet';
|
|
38
|
+
|
|
39
|
+
private static readonly MIN_TUNNEL_INTERVAL = 2000; // 2 seconds minimum between tunnel starts
|
|
40
|
+
|
|
41
|
+
private ngrokProcess: ChildProcess | null = null;
|
|
42
|
+
private tunnelUrl: string | null = null;
|
|
43
|
+
private tunnelPort: number | null = null;
|
|
44
|
+
private startedAt: Date | null = null;
|
|
45
|
+
private lastStartTime = 0;
|
|
46
|
+
private tunnelConfig: TunnelConfig;
|
|
47
|
+
|
|
48
|
+
constructor(runtime?: IAgentRuntime) {
|
|
49
|
+
super(runtime);
|
|
50
|
+
this.tunnelConfig = {
|
|
51
|
+
provider: 'ngrok',
|
|
52
|
+
authToken:
|
|
53
|
+
settingString(runtime?.getSetting('NGROK_AUTH_TOKEN')) ?? process.env.NGROK_AUTH_TOKEN,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async initialize(): Promise<void> {
|
|
58
|
+
elizaLogger.info('🚇 Initializing Ngrok tunnel service...');
|
|
59
|
+
const isInstalled = await this.checkNgrokInstalled();
|
|
60
|
+
if (!isInstalled) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'ngrok is not installed. Please install it from https://ngrok.com/download or run: brew install ngrok'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const authToken =
|
|
67
|
+
this.tunnelConfig.authToken ??
|
|
68
|
+
settingString(this.runtime.getSetting('NGROK_AUTH_TOKEN')) ??
|
|
69
|
+
process.env.NGROK_AUTH_TOKEN;
|
|
70
|
+
|
|
71
|
+
if (authToken) {
|
|
72
|
+
await this.setAuthToken(authToken);
|
|
73
|
+
elizaLogger.info('Setting ngrok auth token');
|
|
74
|
+
} else {
|
|
75
|
+
elizaLogger.warn('No ngrok auth token found - running in limited mode');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static override async start(runtime: IAgentRuntime): Promise<Service> {
|
|
80
|
+
const service = new NgrokService(runtime);
|
|
81
|
+
await service.start();
|
|
82
|
+
return service;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Base Service lifecycle methods
|
|
86
|
+
async start(): Promise<void> {
|
|
87
|
+
elizaLogger.info('NgrokService started');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async stop(): Promise<void> {
|
|
91
|
+
await this.stopTunnel();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ITunnelService implementation
|
|
95
|
+
async startTunnel(port?: number): Promise<string | undefined> {
|
|
96
|
+
if (this.isActive()) {
|
|
97
|
+
elizaLogger.warn('Ngrok tunnel is already running');
|
|
98
|
+
return this.tunnelUrl || undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!port) {
|
|
102
|
+
elizaLogger.warn(
|
|
103
|
+
'NgrokService.start() called without a port. The service will be active but no tunnel will be started.'
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate environment
|
|
109
|
+
try {
|
|
110
|
+
await validateNgrokConfig(this.runtime);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`Ngrok environment validation failed: ${errorMessage(error)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Enforce rate limiting
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
if (this.lastStartTime && now - this.lastStartTime < NgrokService.MIN_TUNNEL_INTERVAL) {
|
|
118
|
+
const waitTime = NgrokService.MIN_TUNNEL_INTERVAL - (now - this.lastStartTime);
|
|
119
|
+
elizaLogger.warn(`Rate limiting: waiting ${waitTime}ms before starting tunnel`);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
121
|
+
}
|
|
122
|
+
this.lastStartTime = Date.now();
|
|
123
|
+
|
|
124
|
+
elizaLogger.info(`🚀 Starting ngrok tunnel on port ${port}...`);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const tunnelUrl = await this.attemptStartTunnel(port);
|
|
128
|
+
this.tunnelUrl = tunnelUrl;
|
|
129
|
+
this.tunnelPort = port;
|
|
130
|
+
this.startedAt = new Date();
|
|
131
|
+
elizaLogger.success(`✅ Ngrok tunnel started: ${tunnelUrl}`);
|
|
132
|
+
return tunnelUrl;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
elizaLogger.error('Failed to start ngrok tunnel:', errorMessage(error));
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async attemptStartTunnel(port: number): Promise<string> {
|
|
140
|
+
let attempts = 0;
|
|
141
|
+
const maxAttempts = 3;
|
|
142
|
+
const baseDelay = 2000;
|
|
143
|
+
|
|
144
|
+
while (attempts < maxAttempts) {
|
|
145
|
+
try {
|
|
146
|
+
return await this.startTunnelInternal(port);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
attempts++;
|
|
149
|
+
|
|
150
|
+
if (errorMessage(error).includes('domain might already be in use')) {
|
|
151
|
+
if (attempts < maxAttempts) {
|
|
152
|
+
elizaLogger.warn(
|
|
153
|
+
`Domain conflict detected, retrying in ${baseDelay * attempts}ms (attempt ${attempts}/${maxAttempts})`
|
|
154
|
+
);
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, baseDelay * attempts));
|
|
156
|
+
|
|
157
|
+
// Try to stop any existing process just in case
|
|
158
|
+
if (this.ngrokProcess) {
|
|
159
|
+
this.ngrokProcess.kill();
|
|
160
|
+
this.ngrokProcess = null;
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new Error(`Failed to start tunnel after ${maxAttempts} attempts`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async startTunnelInternal(port: number): Promise<string> {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const args = ['http', port.toString()];
|
|
177
|
+
if (this.tunnelConfig.region) {
|
|
178
|
+
args.push('--region', this.tunnelConfig.region);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for domain configuration
|
|
182
|
+
const domain =
|
|
183
|
+
settingString(this.runtime.getSetting('NGROK_DOMAIN')) ?? process.env.NGROK_DOMAIN;
|
|
184
|
+
const useRandomSubdomain =
|
|
185
|
+
settingString(this.runtime.getSetting('NGROK_USE_RANDOM_SUBDOMAIN')) === 'true';
|
|
186
|
+
|
|
187
|
+
if (domain && !useRandomSubdomain) {
|
|
188
|
+
args.push('--domain', domain);
|
|
189
|
+
elizaLogger.info(`Using ngrok domain: ${domain}`);
|
|
190
|
+
} else if (this.tunnelConfig.subdomain && !useRandomSubdomain) {
|
|
191
|
+
// Only use subdomain if explicitly configured and not in test mode
|
|
192
|
+
// Note: Subdomains require a paid ngrok account
|
|
193
|
+
args.push('--subdomain', this.tunnelConfig.subdomain);
|
|
194
|
+
elizaLogger.info(`Using configured subdomain: ${this.tunnelConfig.subdomain}`);
|
|
195
|
+
}
|
|
196
|
+
// For free accounts or when random subdomain is requested,
|
|
197
|
+
// don't specify any domain/subdomain - let ngrok generate a random URL
|
|
198
|
+
|
|
199
|
+
this.ngrokProcess = spawn('ngrok', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
200
|
+
|
|
201
|
+
let errorOccurred = false;
|
|
202
|
+
|
|
203
|
+
this.ngrokProcess.on('error', (error) => {
|
|
204
|
+
errorOccurred = true;
|
|
205
|
+
elizaLogger.error(`Failed to start ngrok: ${error.message}`);
|
|
206
|
+
reject(new Error(`Failed to start ngrok: ${error.message}`));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
this.ngrokProcess.stderr?.on('data', (data) => {
|
|
210
|
+
const message = data.toString();
|
|
211
|
+
elizaLogger.error('Ngrok error:', message);
|
|
212
|
+
|
|
213
|
+
if (!errorOccurred) {
|
|
214
|
+
errorOccurred = true;
|
|
215
|
+
// Kill the process to clean up
|
|
216
|
+
if (this.ngrokProcess && !this.ngrokProcess.killed) {
|
|
217
|
+
this.ngrokProcess.kill();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Handle specific error cases
|
|
221
|
+
if (message.includes('invalid port')) {
|
|
222
|
+
reject(new Error('Invalid port specified'));
|
|
223
|
+
} else if (message.includes('address already in use')) {
|
|
224
|
+
reject(new Error('Port is already in use'));
|
|
225
|
+
} else if (message.includes('ERR_NGROK_15002') || message.includes('Pay-as-you-go')) {
|
|
226
|
+
// Pay-as-you-go account requires domain
|
|
227
|
+
if (!domain) {
|
|
228
|
+
reject(
|
|
229
|
+
new Error(
|
|
230
|
+
'Pay-as-you-go ngrok account requires NGROK_DOMAIN to be set. Please set NGROK_DOMAIN=your-domain.ngrok-free.app in your .env file'
|
|
231
|
+
)
|
|
232
|
+
);
|
|
233
|
+
} else {
|
|
234
|
+
reject(
|
|
235
|
+
new Error(
|
|
236
|
+
'Failed to start tunnel with pay-as-you-go account. Ensure your domain is registered at https://dashboard.ngrok.com/domains'
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
} else if (
|
|
241
|
+
message.includes('failed to start tunnel') ||
|
|
242
|
+
message.includes('is already bound to another tunnel') ||
|
|
243
|
+
message.includes('tunnel session failed')
|
|
244
|
+
) {
|
|
245
|
+
// This might happen if the domain is already in use
|
|
246
|
+
reject(new Error('Failed to start tunnel - domain might already be in use'));
|
|
247
|
+
} else {
|
|
248
|
+
reject(new Error(`Ngrok error: ${message}`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Give ngrok more time to start and handle multiple retry attempts
|
|
254
|
+
let retryCount = 0;
|
|
255
|
+
const maxRetries = 3;
|
|
256
|
+
const retryDelay = 2000;
|
|
257
|
+
|
|
258
|
+
const tryFetchUrl = async () => {
|
|
259
|
+
if (errorOccurred) {
|
|
260
|
+
return; // Don't try to fetch URL if we already have an error
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const url = await this.fetchTunnelUrl();
|
|
265
|
+
if (url) {
|
|
266
|
+
this.tunnelUrl = url;
|
|
267
|
+
this.tunnelPort = port;
|
|
268
|
+
this.startedAt = new Date();
|
|
269
|
+
elizaLogger.success(`✅ Ngrok tunnel started: ${url}`);
|
|
270
|
+
resolve(url);
|
|
271
|
+
} else if (retryCount < maxRetries) {
|
|
272
|
+
retryCount++;
|
|
273
|
+
elizaLogger.warn(
|
|
274
|
+
`Retrying to fetch tunnel URL (attempt ${retryCount}/${maxRetries})...`
|
|
275
|
+
);
|
|
276
|
+
setTimeout(tryFetchUrl, retryDelay);
|
|
277
|
+
} else {
|
|
278
|
+
reject(new Error('Failed to get tunnel URL from ngrok after multiple attempts'));
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (retryCount < maxRetries && !errorOccurred) {
|
|
282
|
+
retryCount++;
|
|
283
|
+
elizaLogger.warn(
|
|
284
|
+
`Retrying to fetch tunnel URL (attempt ${retryCount}/${maxRetries})...`
|
|
285
|
+
);
|
|
286
|
+
setTimeout(tryFetchUrl, retryDelay);
|
|
287
|
+
} else {
|
|
288
|
+
reject(error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
setTimeout(tryFetchUrl, 2000);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async stopTunnel(): Promise<void> {
|
|
298
|
+
if (!this.ngrokProcess) {
|
|
299
|
+
elizaLogger.warn('Ngrok tunnel is not running');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
elizaLogger.info('🛑 Stopping ngrok tunnel...');
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
if (this.ngrokProcess) {
|
|
305
|
+
this.ngrokProcess.on('exit', () => {
|
|
306
|
+
this.cleanup();
|
|
307
|
+
elizaLogger.info('✅ Ngrok tunnel stopped');
|
|
308
|
+
resolve();
|
|
309
|
+
});
|
|
310
|
+
this.ngrokProcess.kill();
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
if (this.ngrokProcess && !this.ngrokProcess.killed) {
|
|
313
|
+
this.ngrokProcess.kill('SIGKILL');
|
|
314
|
+
}
|
|
315
|
+
this.cleanup();
|
|
316
|
+
resolve();
|
|
317
|
+
}, 5000);
|
|
318
|
+
} else {
|
|
319
|
+
resolve();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
getUrl(): string | null {
|
|
325
|
+
return this.tunnelUrl;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
isActive(): boolean {
|
|
329
|
+
return this.ngrokProcess !== null && !this.ngrokProcess.killed && this.tunnelUrl !== null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
getStatus(): TunnelStatus {
|
|
333
|
+
return {
|
|
334
|
+
active: this.isActive(),
|
|
335
|
+
url: this.tunnelUrl,
|
|
336
|
+
port: this.tunnelPort,
|
|
337
|
+
startedAt: this.startedAt,
|
|
338
|
+
provider: 'ngrok',
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private cleanup(): void {
|
|
343
|
+
this.ngrokProcess = null;
|
|
344
|
+
this.tunnelUrl = null;
|
|
345
|
+
this.tunnelPort = null;
|
|
346
|
+
this.startedAt = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async checkNgrokInstalled(): Promise<boolean> {
|
|
350
|
+
return new Promise((resolve) => {
|
|
351
|
+
const proc = spawn('which', ['ngrok']);
|
|
352
|
+
proc.on('exit', (code) => resolve(code === 0));
|
|
353
|
+
proc.on('error', () => resolve(false));
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async setAuthToken(token: string): Promise<void> {
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const proc = spawn('ngrok', ['config', 'add-authtoken', token]);
|
|
360
|
+
proc.on('exit', (code) => {
|
|
361
|
+
if (code === 0) {
|
|
362
|
+
elizaLogger.info('✅ Ngrok auth token configured');
|
|
363
|
+
resolve();
|
|
364
|
+
} else {
|
|
365
|
+
reject(new Error('Failed to set ngrok auth token'));
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
proc.on('error', reject);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async fetchTunnelUrl(): Promise<string | null> {
|
|
373
|
+
return new Promise((resolve) => {
|
|
374
|
+
http
|
|
375
|
+
.get('http://localhost:4040/api/tunnels', (res) => {
|
|
376
|
+
let data = '';
|
|
377
|
+
res.on('data', (chunk) => (data += chunk));
|
|
378
|
+
res.on('end', () => {
|
|
379
|
+
try {
|
|
380
|
+
const tunnels = JSON.parse(data) as NgrokApiResponse;
|
|
381
|
+
const httpsTunnel = tunnels.tunnels?.find((t) => t.proto === 'https');
|
|
382
|
+
if (httpsTunnel?.public_url) {
|
|
383
|
+
resolve(httpsTunnel.public_url);
|
|
384
|
+
} else {
|
|
385
|
+
elizaLogger.warn('No HTTPS tunnel found in ngrok response');
|
|
386
|
+
resolve(null);
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
390
|
+
elizaLogger.error(`Failed to parse ngrok API response: ${msg}`);
|
|
391
|
+
resolve(null);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
})
|
|
395
|
+
.on('error', (error) => {
|
|
396
|
+
elizaLogger.error(`Failed to connect to ngrok API: ${error.message}`);
|
|
397
|
+
resolve(null);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|