@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.
Files changed (48) hide show
  1. package/README.md +325 -0
  2. package/dist/__tests__/NgrokTestSuite.d.ts +6 -0
  3. package/dist/__tests__/NgrokTestSuite.d.ts.map +1 -0
  4. package/dist/__tests__/NgrokTestSuite.js +92 -0
  5. package/dist/__tests__/NgrokTestSuite.js.map +1 -0
  6. package/dist/actions/get-tunnel-status.d.ts +4 -0
  7. package/dist/actions/get-tunnel-status.d.ts.map +1 -0
  8. package/dist/actions/get-tunnel-status.js +186 -0
  9. package/dist/actions/get-tunnel-status.js.map +1 -0
  10. package/dist/actions/start-tunnel.d.ts +4 -0
  11. package/dist/actions/start-tunnel.d.ts.map +1 -0
  12. package/dist/actions/start-tunnel.js +221 -0
  13. package/dist/actions/start-tunnel.js.map +1 -0
  14. package/dist/actions/stop-tunnel.d.ts +4 -0
  15. package/dist/actions/stop-tunnel.d.ts.map +1 -0
  16. package/dist/actions/stop-tunnel.js +174 -0
  17. package/dist/actions/stop-tunnel.js.map +1 -0
  18. package/dist/environment.d.ts +12 -0
  19. package/dist/environment.d.ts.map +1 -0
  20. package/dist/environment.js +68 -0
  21. package/dist/environment.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/services/NgrokService.d.ts +30 -0
  27. package/dist/services/NgrokService.d.ts.map +1 -0
  28. package/dist/services/NgrokService.js +333 -0
  29. package/dist/services/NgrokService.js.map +1 -0
  30. package/package.json +63 -0
  31. package/src/__tests__/NgrokTestSuite.ts +110 -0
  32. package/src/__tests__/debug-mock.test.ts +15 -0
  33. package/src/__tests__/e2e/real-ngrok.test.ts +543 -0
  34. package/src/__tests__/integration/webhook-scenarios.test.ts +463 -0
  35. package/src/__tests__/mocks/NgrokServiceMock.ts +76 -0
  36. package/src/__tests__/ngrok-integration.test.ts +521 -0
  37. package/src/__tests__/test-config.ts +83 -0
  38. package/src/__tests__/test-helpers.ts +43 -0
  39. package/src/__tests__/test-setup.ts +174 -0
  40. package/src/__tests__/test-utils.ts +155 -0
  41. package/src/__tests__/unit/actions.test.ts +402 -0
  42. package/src/__tests__/unit/environment.test.ts +352 -0
  43. package/src/actions/get-tunnel-status.ts +218 -0
  44. package/src/actions/start-tunnel.ts +255 -0
  45. package/src/actions/stop-tunnel.ts +203 -0
  46. package/src/environment.ts +75 -0
  47. package/src/index.ts +33 -0
  48. 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
+ }