@robota-sdk/agent-plugin 3.0.0-beta.64

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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/index.cjs +1 -0
  3. package/dist/node/index.d.ts +1724 -0
  4. package/dist/node/index.d.ts.map +1 -0
  5. package/dist/node/index.js +2 -0
  6. package/dist/node/index.js.map +1 -0
  7. package/package.json +48 -0
  8. package/src/conversation-history/__tests__/conversation-history-plugin.test.ts +221 -0
  9. package/src/conversation-history/__tests__/history-storages.test.ts +115 -0
  10. package/src/conversation-history/conversation-history-helpers.ts +120 -0
  11. package/src/conversation-history/conversation-history-plugin.ts +294 -0
  12. package/src/conversation-history/index.ts +11 -0
  13. package/src/conversation-history/storages/database-storage.ts +96 -0
  14. package/src/conversation-history/storages/file-storage.ts +95 -0
  15. package/src/conversation-history/storages/index.ts +3 -0
  16. package/src/conversation-history/storages/memory-storage.ts +44 -0
  17. package/src/conversation-history/types.ts +64 -0
  18. package/src/error-handling/__tests__/error-handling-plugin.test.ts +201 -0
  19. package/src/error-handling/context-adapter.ts +48 -0
  20. package/src/error-handling/error-handling-helpers.ts +53 -0
  21. package/src/error-handling/error-handling-plugin.ts +293 -0
  22. package/src/error-handling/index.ts +9 -0
  23. package/src/error-handling/types.ts +82 -0
  24. package/src/execution-analytics/__tests__/execution-analytics-plugin.test.ts +224 -0
  25. package/src/execution-analytics/analytics-aggregation.ts +88 -0
  26. package/src/execution-analytics/execution-analytics-helpers.ts +83 -0
  27. package/src/execution-analytics/execution-analytics-plugin.ts +315 -0
  28. package/src/execution-analytics/index.ts +9 -0
  29. package/src/execution-analytics/types.ts +97 -0
  30. package/src/index.ts +8 -0
  31. package/src/limits/__tests__/limits-plugin.test.ts +712 -0
  32. package/src/limits/index.ts +9 -0
  33. package/src/limits/limits-helpers.ts +185 -0
  34. package/src/limits/limits-plugin.ts +196 -0
  35. package/src/limits/types.ts +73 -0
  36. package/src/limits/validation.ts +81 -0
  37. package/src/logging/__tests__/formatters.test.ts +48 -0
  38. package/src/logging/__tests__/logging-plugin.test.ts +464 -0
  39. package/src/logging/__tests__/logging-storages.test.ts +95 -0
  40. package/src/logging/formatters.ts +28 -0
  41. package/src/logging/index.ts +15 -0
  42. package/src/logging/logging-helpers.ts +223 -0
  43. package/src/logging/logging-plugin.ts +288 -0
  44. package/src/logging/storages/console-storage.ts +44 -0
  45. package/src/logging/storages/file-storage.ts +44 -0
  46. package/src/logging/storages/index.ts +4 -0
  47. package/src/logging/storages/remote-storage.ts +78 -0
  48. package/src/logging/storages/silent-storage.ts +18 -0
  49. package/src/logging/types.ts +106 -0
  50. package/src/performance/__tests__/memory-storage.test.ts +86 -0
  51. package/src/performance/__tests__/performance-plugin.test.ts +208 -0
  52. package/src/performance/__tests__/system-metrics-collector.test.ts +33 -0
  53. package/src/performance/collectors/system-metrics-collector.ts +69 -0
  54. package/src/performance/index.ts +12 -0
  55. package/src/performance/performance-helpers.ts +86 -0
  56. package/src/performance/performance-plugin.ts +274 -0
  57. package/src/performance/storages/index.ts +1 -0
  58. package/src/performance/storages/memory-storage.ts +88 -0
  59. package/src/performance/types.ts +160 -0
  60. package/src/usage/__tests__/aggregate-usage-stats.test.ts +136 -0
  61. package/src/usage/__tests__/memory-storage.test.ts +83 -0
  62. package/src/usage/__tests__/silent-storage.test.ts +44 -0
  63. package/src/usage/__tests__/usage-plugin-helpers.test.ts +155 -0
  64. package/src/usage/__tests__/usage-plugin.test.ts +358 -0
  65. package/src/usage/aggregate-usage-stats.ts +142 -0
  66. package/src/usage/index.ts +14 -0
  67. package/src/usage/storages/file-storage.ts +115 -0
  68. package/src/usage/storages/index.ts +4 -0
  69. package/src/usage/storages/memory-storage.ts +61 -0
  70. package/src/usage/storages/remote-storage.ts +143 -0
  71. package/src/usage/storages/silent-storage.ts +38 -0
  72. package/src/usage/types.ts +132 -0
  73. package/src/usage/usage-plugin-helpers.ts +116 -0
  74. package/src/usage/usage-plugin.ts +296 -0
  75. package/src/webhook/__tests__/webhook-plugin.test.ts +560 -0
  76. package/src/webhook/http-client.ts +141 -0
  77. package/src/webhook/index.ts +9 -0
  78. package/src/webhook/transformer.ts +209 -0
  79. package/src/webhook/types.ts +201 -0
  80. package/src/webhook/webhook-helpers.ts +60 -0
  81. package/src/webhook/webhook-plugin.ts +298 -0
  82. package/src/webhook/webhook-queue.ts +148 -0
@@ -0,0 +1,560 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { PluginError } from '@robota-sdk/agent-core';
3
+ import type {
4
+ IPluginExecutionContext,
5
+ IPluginExecutionResult,
6
+ IPluginErrorContext,
7
+ } from '@robota-sdk/agent-core';
8
+
9
+ // Mock logger before importing WebhookPlugin
10
+ vi.mock('@robota-sdk/agent-core', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('@robota-sdk/agent-core')>();
12
+ return {
13
+ ...actual,
14
+ createLogger: vi.fn().mockReturnValue({
15
+ debug: vi.fn(),
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ error: vi.fn(),
19
+ isDebugEnabled: vi.fn().mockReturnValue(false),
20
+ setLevel: vi.fn(),
21
+ getLevel: vi.fn().mockReturnValue('warn'),
22
+ }),
23
+ };
24
+ });
25
+
26
+ import { WebhookPlugin } from '../webhook-plugin';
27
+ import type { IWebhookEndpoint, IWebhookEventData, TWebhookEventName } from '../types';
28
+
29
+ function createContext(overrides: Partial<IPluginExecutionContext> = {}): IPluginExecutionContext {
30
+ return {
31
+ executionId: 'exec_1',
32
+ sessionId: 'session_1',
33
+ userId: 'user_1',
34
+ messages: [
35
+ {
36
+ id: 'msg-1',
37
+ role: 'user',
38
+ content: 'hello',
39
+ state: 'complete' as const,
40
+ timestamp: new Date(),
41
+ },
42
+ ],
43
+ config: { model: 'gpt-4' },
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ function createResult(overrides: Partial<IPluginExecutionResult> = {}): IPluginExecutionResult {
49
+ return {
50
+ success: true,
51
+ response: 'Hello there',
52
+ duration: 150,
53
+ tokensUsed: 42,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function createEndpoint(overrides: Partial<IWebhookEndpoint> = {}): IWebhookEndpoint {
59
+ return {
60
+ url: 'https://example.com/webhook',
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe('WebhookPlugin', () => {
66
+ beforeEach(() => {
67
+ // Mock global fetch
68
+ vi.stubGlobal(
69
+ 'fetch',
70
+ vi.fn().mockResolvedValue({
71
+ ok: true,
72
+ status: 200,
73
+ statusText: 'OK',
74
+ }),
75
+ );
76
+ });
77
+
78
+ afterEach(() => {
79
+ vi.unstubAllGlobals();
80
+ vi.clearAllMocks();
81
+ });
82
+
83
+ // ----------------------------------------------------------------
84
+ // Construction
85
+ // ----------------------------------------------------------------
86
+ describe('construction', () => {
87
+ it('should create plugin with valid endpoint', () => {
88
+ const plugin = new WebhookPlugin({
89
+ endpoints: [createEndpoint()],
90
+ });
91
+
92
+ expect(plugin.name).toBe('WebhookPlugin');
93
+ expect(plugin.version).toBe('1.0.0');
94
+ });
95
+
96
+ it('should throw when no endpoints are provided', () => {
97
+ expect(
98
+ () =>
99
+ new WebhookPlugin({
100
+ endpoints: [],
101
+ }),
102
+ ).toThrow(PluginError);
103
+ });
104
+
105
+ it('should throw when endpoints array is missing', () => {
106
+ expect(
107
+ () =>
108
+ new WebhookPlugin({
109
+ endpoints: undefined as unknown as IWebhookEndpoint[],
110
+ }),
111
+ ).toThrow(PluginError);
112
+ });
113
+
114
+ it('should throw when endpoint URL is missing', () => {
115
+ expect(
116
+ () =>
117
+ new WebhookPlugin({
118
+ endpoints: [{ url: '' }],
119
+ }),
120
+ ).toThrow(PluginError);
121
+ });
122
+
123
+ it('should throw when endpoint URL is invalid', () => {
124
+ expect(
125
+ () =>
126
+ new WebhookPlugin({
127
+ endpoints: [{ url: 'not-a-url' }],
128
+ }),
129
+ ).toThrow(PluginError);
130
+ });
131
+
132
+ it('should accept multiple endpoints', () => {
133
+ const plugin = new WebhookPlugin({
134
+ endpoints: [
135
+ createEndpoint({ url: 'https://a.com/hook' }),
136
+ createEndpoint({ url: 'https://b.com/hook' }),
137
+ ],
138
+ });
139
+
140
+ const stats = plugin.getStats();
141
+ expect(stats.endpointCount).toBe(2);
142
+ });
143
+
144
+ it('should throw when endpoint URL uses file:// scheme', () => {
145
+ expect(
146
+ () =>
147
+ new WebhookPlugin({
148
+ endpoints: [{ url: 'file:///etc/passwd' }],
149
+ }),
150
+ ).toThrow(PluginError);
151
+ expect(
152
+ () =>
153
+ new WebhookPlugin({
154
+ endpoints: [{ url: 'file:///etc/passwd' }],
155
+ }),
156
+ ).toThrow('Webhook endpoint URL must use http or https');
157
+ });
158
+
159
+ it('should throw when endpoint URL uses ftp:// scheme', () => {
160
+ expect(
161
+ () =>
162
+ new WebhookPlugin({
163
+ endpoints: [{ url: 'ftp://example.com/data' }],
164
+ }),
165
+ ).toThrow(PluginError);
166
+ });
167
+
168
+ it('should accept http:// endpoint URL', () => {
169
+ const plugin = new WebhookPlugin({
170
+ endpoints: [{ url: 'http://localhost:3000/hook' }],
171
+ });
172
+ expect(plugin.name).toBe('WebhookPlugin');
173
+ });
174
+
175
+ it('should accept https:// endpoint URL', () => {
176
+ const plugin = new WebhookPlugin({
177
+ endpoints: [{ url: 'https://example.com/hook' }],
178
+ });
179
+ expect(plugin.name).toBe('WebhookPlugin');
180
+ });
181
+
182
+ it('should throw on invalid event filter in endpoint', () => {
183
+ expect(
184
+ () =>
185
+ new WebhookPlugin({
186
+ endpoints: [
187
+ {
188
+ url: 'https://example.com/hook',
189
+ events: ['invalid.event' as TWebhookEventName],
190
+ },
191
+ ],
192
+ }),
193
+ ).toThrow(PluginError);
194
+ });
195
+ });
196
+
197
+ // ----------------------------------------------------------------
198
+ // Event filtering
199
+ // ----------------------------------------------------------------
200
+ describe('event filtering', () => {
201
+ it('should skip events not in plugin event list', async () => {
202
+ const plugin = new WebhookPlugin({
203
+ endpoints: [createEndpoint()],
204
+ events: ['error.occurred'],
205
+ async: false,
206
+ });
207
+
208
+ // This event is not in the events list, so fetch should not be called
209
+ await plugin.sendWebhook('custom', { executionId: 'test' });
210
+ expect(fetch).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('should send events that are in the plugin event list', async () => {
214
+ const plugin = new WebhookPlugin({
215
+ endpoints: [createEndpoint()],
216
+ events: ['custom'],
217
+ async: false,
218
+ });
219
+
220
+ await plugin.sendWebhook('custom', { executionId: 'test' });
221
+ expect(fetch).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it('should filter endpoints by their event subscriptions', async () => {
225
+ const plugin = new WebhookPlugin({
226
+ endpoints: [
227
+ { url: 'https://a.com/hook', events: ['error.occurred'] },
228
+ { url: 'https://b.com/hook', events: ['custom'] },
229
+ ],
230
+ events: ['custom', 'error.occurred'],
231
+ async: false,
232
+ });
233
+
234
+ await plugin.sendWebhook('custom', { executionId: 'test' });
235
+
236
+ // Only endpoint B should receive the custom event
237
+ expect(fetch).toHaveBeenCalledTimes(1);
238
+ const callUrl = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
239
+ expect(callUrl).toBe('https://b.com/hook');
240
+ });
241
+
242
+ it('should send to endpoint with no event filter (receives all)', async () => {
243
+ const plugin = new WebhookPlugin({
244
+ endpoints: [
245
+ { url: 'https://all.com/hook' }, // no events filter = all events
246
+ ],
247
+ events: ['custom'],
248
+ async: false,
249
+ });
250
+
251
+ await plugin.sendWebhook('custom', { executionId: 'test' });
252
+ expect(fetch).toHaveBeenCalledTimes(1);
253
+ });
254
+ });
255
+
256
+ // ----------------------------------------------------------------
257
+ // Lifecycle hooks
258
+ // ----------------------------------------------------------------
259
+ describe('lifecycle hooks', () => {
260
+ it('should send webhook on afterExecution', async () => {
261
+ const plugin = new WebhookPlugin({
262
+ endpoints: [createEndpoint()],
263
+ async: false,
264
+ });
265
+
266
+ await plugin.afterExecution(createContext(), createResult());
267
+ expect(fetch).toHaveBeenCalled();
268
+ });
269
+
270
+ it('should send webhook on afterConversation', async () => {
271
+ const plugin = new WebhookPlugin({
272
+ endpoints: [createEndpoint()],
273
+ async: false,
274
+ });
275
+
276
+ await plugin.afterConversation(createContext(), createResult());
277
+ expect(fetch).toHaveBeenCalled();
278
+ });
279
+
280
+ it('should send webhook on onError', async () => {
281
+ const plugin = new WebhookPlugin({
282
+ endpoints: [createEndpoint()],
283
+ async: false,
284
+ });
285
+
286
+ const error = new Error('Test error');
287
+ const errorContext: IPluginErrorContext = {
288
+ action: 'test',
289
+ executionId: 'exec_1',
290
+ sessionId: 'session_1',
291
+ userId: 'user_1',
292
+ };
293
+
294
+ await plugin.onError(error, errorContext);
295
+ // onError sends two webhooks: error.occurred and execution.error
296
+ expect(fetch).toHaveBeenCalled();
297
+ });
298
+
299
+ it('should send webhook on afterToolExecution with tool calls', async () => {
300
+ const plugin = new WebhookPlugin({
301
+ endpoints: [createEndpoint()],
302
+ async: false,
303
+ });
304
+
305
+ const resultWithTools = createResult({
306
+ toolCalls: [
307
+ { id: 'tool-1', name: 'search', result: 'found' },
308
+ { id: 'tool-2', name: 'calc', result: '42' },
309
+ ],
310
+ });
311
+
312
+ await plugin.afterToolExecution(createContext(), resultWithTools);
313
+ // Should send one webhook per tool call
314
+ expect(fetch).toHaveBeenCalledTimes(2);
315
+ });
316
+
317
+ it('should not send on afterToolExecution with no tool calls', async () => {
318
+ const plugin = new WebhookPlugin({
319
+ endpoints: [createEndpoint()],
320
+ async: false,
321
+ });
322
+
323
+ await plugin.afterToolExecution(createContext(), createResult());
324
+ expect(fetch).not.toHaveBeenCalled();
325
+ });
326
+ });
327
+
328
+ // ----------------------------------------------------------------
329
+ // Custom webhooks
330
+ // ----------------------------------------------------------------
331
+ describe('sendCustomWebhook', () => {
332
+ it('should send a custom event webhook', async () => {
333
+ const plugin = new WebhookPlugin({
334
+ endpoints: [createEndpoint()],
335
+ events: ['custom'],
336
+ async: false,
337
+ });
338
+
339
+ await plugin.sendCustomWebhook({ executionId: 'test-exec' });
340
+ expect(fetch).toHaveBeenCalledTimes(1);
341
+
342
+ const body = JSON.parse(
343
+ (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
344
+ ) as Record<string, unknown>;
345
+ expect(body.event).toBe('custom');
346
+ });
347
+ });
348
+
349
+ // ----------------------------------------------------------------
350
+ // Payload structure
351
+ // ----------------------------------------------------------------
352
+ describe('payload structure', () => {
353
+ it('should include event, timestamp, and data in payload', async () => {
354
+ const plugin = new WebhookPlugin({
355
+ endpoints: [createEndpoint()],
356
+ events: ['custom'],
357
+ async: false,
358
+ });
359
+
360
+ await plugin.sendWebhook('custom', {
361
+ executionId: 'exec-123',
362
+ sessionId: 'sess-456',
363
+ });
364
+
365
+ const body = JSON.parse(
366
+ (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
367
+ ) as Record<string, unknown>;
368
+ expect(body.event).toBe('custom');
369
+ expect(body.timestamp).toBeDefined();
370
+ expect(body.executionId).toBe('exec-123');
371
+ expect(body.sessionId).toBe('sess-456');
372
+ });
373
+
374
+ it('should include metadata when provided', async () => {
375
+ const plugin = new WebhookPlugin({
376
+ endpoints: [createEndpoint()],
377
+ events: ['custom'],
378
+ async: false,
379
+ });
380
+
381
+ await plugin.sendWebhook('custom', { executionId: 'test' }, { source: 'test-suite' });
382
+
383
+ const body = JSON.parse(
384
+ (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
385
+ ) as Record<string, unknown>;
386
+ expect(body.metadata).toEqual({ source: 'test-suite' });
387
+ });
388
+ });
389
+
390
+ // ----------------------------------------------------------------
391
+ // Batching
392
+ // ----------------------------------------------------------------
393
+ describe('batching', () => {
394
+ it('should queue payloads when batching is enabled', async () => {
395
+ const plugin = new WebhookPlugin({
396
+ endpoints: [createEndpoint()],
397
+ events: ['custom'],
398
+ async: false,
399
+ batching: { enabled: true, maxSize: 5, flushInterval: 60000 },
400
+ });
401
+
402
+ await plugin.sendWebhook('custom', { executionId: '1' });
403
+ await plugin.sendWebhook('custom', { executionId: '2' });
404
+
405
+ // Not flushed yet because batch size is 5
406
+ expect(fetch).not.toHaveBeenCalled();
407
+
408
+ const stats = plugin.getStats();
409
+ expect(stats.batchQueueLength).toBe(2);
410
+
411
+ await plugin.destroy();
412
+ });
413
+
414
+ it('should auto-flush when batch reaches maxSize', async () => {
415
+ const plugin = new WebhookPlugin({
416
+ endpoints: [createEndpoint()],
417
+ events: ['custom'],
418
+ async: false,
419
+ batching: { enabled: true, maxSize: 2, flushInterval: 60000 },
420
+ });
421
+
422
+ await plugin.sendWebhook('custom', { executionId: '1' });
423
+ await plugin.sendWebhook('custom', { executionId: '2' });
424
+
425
+ // Should have flushed since we hit maxSize of 2
426
+ expect(fetch).toHaveBeenCalledTimes(2);
427
+
428
+ await plugin.destroy();
429
+ });
430
+ });
431
+
432
+ // ----------------------------------------------------------------
433
+ // Statistics
434
+ // ----------------------------------------------------------------
435
+ describe('getStats', () => {
436
+ it('should return complete stats', async () => {
437
+ const plugin = new WebhookPlugin({
438
+ endpoints: [createEndpoint(), createEndpoint({ url: 'https://b.com/hook' })],
439
+ events: ['custom'],
440
+ async: false,
441
+ });
442
+
443
+ await plugin.sendWebhook('custom', { executionId: 'test' });
444
+
445
+ const stats = plugin.getStats();
446
+ expect(stats.endpointCount).toBe(2);
447
+ expect(stats.totalSent).toBe(2); // sent to both endpoints
448
+ expect(stats.totalErrors).toBe(0);
449
+ expect(stats.supportedEvents).toContain('custom');
450
+ expect(typeof stats.averageResponseTime).toBe('number');
451
+ });
452
+
453
+ it('should track errors in stats', async () => {
454
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
455
+
456
+ const plugin = new WebhookPlugin({
457
+ endpoints: [
458
+ {
459
+ url: 'https://example.com/hook',
460
+ retries: 0,
461
+ timeout: 100,
462
+ },
463
+ ],
464
+ events: ['custom'],
465
+ async: false,
466
+ });
467
+
468
+ // sendWebhook catches errors internally and tracks them in stats
469
+ await plugin.sendWebhook('custom', { executionId: 'test' });
470
+
471
+ const stats = plugin.getStats();
472
+ expect(stats.totalErrors).toBeGreaterThan(0);
473
+ });
474
+ });
475
+
476
+ // ----------------------------------------------------------------
477
+ // Queue management
478
+ // ----------------------------------------------------------------
479
+ describe('clearQueue', () => {
480
+ it('should clear both request and batch queues', async () => {
481
+ const plugin = new WebhookPlugin({
482
+ endpoints: [createEndpoint()],
483
+ events: ['custom'],
484
+ async: false,
485
+ batching: { enabled: true, maxSize: 100, flushInterval: 60000 },
486
+ });
487
+
488
+ await plugin.sendWebhook('custom', { executionId: '1' });
489
+ await plugin.sendWebhook('custom', { executionId: '2' });
490
+
491
+ plugin.clearQueue();
492
+
493
+ const stats = plugin.getStats();
494
+ expect(stats.queueLength).toBe(0);
495
+ expect(stats.batchQueueLength).toBe(0);
496
+
497
+ await plugin.destroy();
498
+ });
499
+ });
500
+
501
+ // ----------------------------------------------------------------
502
+ // Destroy
503
+ // ----------------------------------------------------------------
504
+ describe('destroy', () => {
505
+ it('should flush batched payloads on destroy', async () => {
506
+ const plugin = new WebhookPlugin({
507
+ endpoints: [createEndpoint()],
508
+ events: ['custom'],
509
+ async: false,
510
+ batching: { enabled: true, maxSize: 100, flushInterval: 60000 },
511
+ });
512
+
513
+ await plugin.sendWebhook('custom', { executionId: '1' });
514
+ expect(fetch).not.toHaveBeenCalled();
515
+
516
+ await plugin.destroy();
517
+ expect(fetch).toHaveBeenCalled();
518
+ });
519
+
520
+ it('should complete without error when queue is empty', async () => {
521
+ const plugin = new WebhookPlugin({
522
+ endpoints: [createEndpoint()],
523
+ });
524
+
525
+ await expect(plugin.destroy()).resolves.not.toThrow();
526
+ });
527
+ });
528
+
529
+ // ----------------------------------------------------------------
530
+ // Payload transformer
531
+ // ----------------------------------------------------------------
532
+ describe('payload transformer', () => {
533
+ it('should apply custom payload transformer', async () => {
534
+ const transformer = vi.fn((_event: TWebhookEventName, data: IWebhookEventData) => ({
535
+ ...data,
536
+ executionId: 'transformed',
537
+ }));
538
+
539
+ const plugin = new WebhookPlugin({
540
+ endpoints: [createEndpoint()],
541
+ events: ['custom'],
542
+ async: false,
543
+ payloadTransformer: transformer,
544
+ });
545
+
546
+ await plugin.sendWebhook('custom', { executionId: 'original' });
547
+
548
+ expect(transformer).toHaveBeenCalledWith(
549
+ 'custom',
550
+ expect.objectContaining({ executionId: 'original' }),
551
+ );
552
+
553
+ const body = JSON.parse(
554
+ (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string,
555
+ ) as Record<string, unknown>;
556
+ const data = body.data as Record<string, unknown>;
557
+ expect(data.executionId).toBe('transformed');
558
+ });
559
+ });
560
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Webhook HTTP client for sending webhook requests
3
+ * Handles retries, timeouts, and error scenarios
4
+ */
5
+
6
+ import jsSHA from 'jssha';
7
+ import type { IWebhookRequest } from './types';
8
+ import type { ILogger } from '@robota-sdk/agent-core';
9
+
10
+ const DEFAULT_TIMEOUT_MS = 5000;
11
+ const DEFAULT_MAX_RETRIES = 3;
12
+ const BACKOFF_BASE_MS = 1000;
13
+ const MAX_BACKOFF_MS = 10000;
14
+
15
+ /**
16
+ * HTTP client for webhook requests
17
+ */
18
+ export class WebhookHttpClient {
19
+ private logger: ILogger;
20
+
21
+ constructor(logger: ILogger) {
22
+ this.logger = logger;
23
+ }
24
+
25
+ /**
26
+ * Send a single webhook request with retries
27
+ */
28
+ async sendRequest(request: IWebhookRequest): Promise<void> {
29
+ const { endpoint, payload, attempt } = request;
30
+ const timeout = endpoint.timeout ?? DEFAULT_TIMEOUT_MS;
31
+ const maxRetries = endpoint.retries ?? DEFAULT_MAX_RETRIES;
32
+
33
+ try {
34
+ // Prepare request body
35
+ const body = JSON.stringify(payload);
36
+
37
+ // Prepare headers
38
+ const headers: Record<string, string> = {
39
+ 'Content-Type': 'application/json',
40
+ 'User-Agent': 'robota-webhook/1.0.0',
41
+ ...endpoint.headers,
42
+ };
43
+
44
+ // Add signature if secret is provided
45
+ if (endpoint.secret) {
46
+ const signature = this.generateSignature(body, endpoint.secret);
47
+ headers['X-Webhook-Signature'] = signature;
48
+ }
49
+
50
+ // Make HTTP request
51
+ const response = await this.makeHttpRequest(endpoint.url, {
52
+ method: 'POST',
53
+ headers,
54
+ body,
55
+ timeout,
56
+ });
57
+
58
+ if (!response.ok) {
59
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
60
+ }
61
+
62
+ this.logger.debug('Webhook sent successfully', {
63
+ url: endpoint.url,
64
+ event: payload.event,
65
+ attempt,
66
+ status: response.status,
67
+ });
68
+ } catch (error) {
69
+ this.logger.error('Webhook request failed', {
70
+ url: endpoint.url,
71
+ event: payload.event,
72
+ attempt,
73
+ error: error instanceof Error ? error.message : String(error),
74
+ });
75
+
76
+ // Retry if we haven't exceeded max attempts
77
+ if (attempt < maxRetries) {
78
+ const retryRequest: IWebhookRequest = {
79
+ ...request,
80
+ attempt: attempt + 1,
81
+ };
82
+
83
+ // Exponential backoff
84
+ const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
85
+ await this.delay(delay);
86
+
87
+ return this.sendRequest(retryRequest);
88
+ }
89
+
90
+ // Max retries exceeded
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Generate HMAC signature for webhook security using jsSHA (browser compatible)
97
+ */
98
+ private generateSignature(body: string, secret: string): string {
99
+ const shaObj = new jsSHA('SHA-256', 'TEXT', {
100
+ hmacKey: { value: secret, format: 'TEXT' },
101
+ });
102
+ shaObj.update(body);
103
+ return shaObj.getHash('HEX').toLowerCase();
104
+ }
105
+
106
+ /**
107
+ * Make HTTP request with timeout support
108
+ */
109
+ private async makeHttpRequest(
110
+ url: string,
111
+ options: {
112
+ method: string;
113
+ headers: Record<string, string>;
114
+ body: string;
115
+ timeout: number;
116
+ },
117
+ ): Promise<Response> {
118
+ const controller = new AbortController();
119
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout);
120
+
121
+ try {
122
+ const response = await fetch(url, {
123
+ method: options.method,
124
+ headers: options.headers,
125
+ body: options.body,
126
+ signal: controller.signal,
127
+ });
128
+
129
+ return response;
130
+ } finally {
131
+ clearTimeout(timeoutId);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Delay utility for retry backoff
137
+ */
138
+ private delay(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+ }
@@ -0,0 +1,9 @@
1
+ export { WebhookPlugin } from './webhook-plugin';
2
+ export { WebhookTransformer } from './transformer';
3
+ export { WebhookHttpClient } from './http-client';
4
+ export type {
5
+ TWebhookEventName,
6
+ IWebhookPayload,
7
+ IWebhookEndpoint,
8
+ IWebhookPluginOptions,
9
+ } from './types';