@rotateprotocol/sdk 1.0.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/README.md +453 -0
- package/dist/catalog.d.ts +112 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +210 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/CheckoutForm.d.ts +86 -0
- package/dist/components/CheckoutForm.d.ts.map +1 -0
- package/dist/components/CheckoutForm.js +332 -0
- package/dist/components/CheckoutForm.js.map +1 -0
- package/dist/components/HostedCheckout.d.ts +57 -0
- package/dist/components/HostedCheckout.d.ts.map +1 -0
- package/dist/components/HostedCheckout.js +414 -0
- package/dist/components/HostedCheckout.js.map +1 -0
- package/dist/components/PaymentButton.d.ts +80 -0
- package/dist/components/PaymentButton.d.ts.map +1 -0
- package/dist/components/PaymentButton.js +210 -0
- package/dist/components/PaymentButton.js.map +1 -0
- package/dist/components/RotateProvider.d.ts +115 -0
- package/dist/components/RotateProvider.d.ts.map +1 -0
- package/dist/components/RotateProvider.js +264 -0
- package/dist/components/RotateProvider.js.map +1 -0
- package/dist/components/index.d.ts +17 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +27 -0
- package/dist/components/index.js.map +1 -0
- package/dist/embed.d.ts +85 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +313 -0
- package/dist/embed.js.map +1 -0
- package/dist/hooks.d.ts +156 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +280 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idl/rotate_connect.json +2572 -0
- package/dist/index.d.ts +505 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1197 -0
- package/dist/index.js.map +1 -0
- package/dist/marketplace.d.ts +257 -0
- package/dist/marketplace.d.ts.map +1 -0
- package/dist/marketplace.js +433 -0
- package/dist/marketplace.js.map +1 -0
- package/dist/platform.d.ts +234 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +268 -0
- package/dist/platform.js.map +1 -0
- package/dist/react.d.ts +140 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +429 -0
- package/dist/react.js.map +1 -0
- package/dist/store.d.ts +213 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +404 -0
- package/dist/store.js.map +1 -0
- package/dist/webhooks.d.ts +149 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +371 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +114 -0
- package/src/catalog.ts +299 -0
- package/src/components/CheckoutForm.tsx +608 -0
- package/src/components/HostedCheckout.tsx +675 -0
- package/src/components/PaymentButton.tsx +348 -0
- package/src/components/RotateProvider.tsx +370 -0
- package/src/components/index.ts +26 -0
- package/src/embed.ts +408 -0
- package/src/hooks.ts +518 -0
- package/src/idl/rotate_connect.json +2572 -0
- package/src/index.ts +1538 -0
- package/src/marketplace.ts +642 -0
- package/src/platform.ts +403 -0
- package/src/react.ts +459 -0
- package/src/store.ts +577 -0
- package/src/webhooks.ts +506 -0
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotate Protocol Webhooks
|
|
3
|
+
*
|
|
4
|
+
* Listen to on-chain payment events and receive notifications.
|
|
5
|
+
* This does NOT change the P2P nature - webhooks are notifications only.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Connection, PublicKey } from '@solana/web3.js';
|
|
11
|
+
import { BorshCoder } from '@coral-xyz/anchor';
|
|
12
|
+
import { PROGRAM_ID, Network } from './index';
|
|
13
|
+
import IDL from './idl/rotate_connect.json';
|
|
14
|
+
|
|
15
|
+
// ==================== TYPES ====================
|
|
16
|
+
|
|
17
|
+
export type WebhookEventType =
|
|
18
|
+
| 'protocol.initialized'
|
|
19
|
+
| 'protocol.updated'
|
|
20
|
+
| 'platform.created'
|
|
21
|
+
| 'platform.updated'
|
|
22
|
+
| 'merchant.created'
|
|
23
|
+
| 'merchant.updated'
|
|
24
|
+
| 'payment.completed'
|
|
25
|
+
| 'payment.pending'
|
|
26
|
+
| 'link.created'
|
|
27
|
+
| 'link.created.usd'
|
|
28
|
+
| 'link.paid'
|
|
29
|
+
| 'link.paid.usd'
|
|
30
|
+
| 'link.cancelled'
|
|
31
|
+
| 'link.closed'
|
|
32
|
+
| 'link.expired';
|
|
33
|
+
|
|
34
|
+
export interface WebhookEvent {
|
|
35
|
+
/** Unique event ID */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Event type */
|
|
38
|
+
type: WebhookEventType;
|
|
39
|
+
/** Timestamp */
|
|
40
|
+
timestamp: number;
|
|
41
|
+
/** Transaction signature */
|
|
42
|
+
transactionSignature: string;
|
|
43
|
+
/** Event data */
|
|
44
|
+
data: WebhookEventData;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WebhookEventData {
|
|
48
|
+
linkId?: number;
|
|
49
|
+
merchantId?: number;
|
|
50
|
+
platformId?: number;
|
|
51
|
+
payerWallet?: string;
|
|
52
|
+
merchantWallet?: string;
|
|
53
|
+
platformWallet?: string;
|
|
54
|
+
treasuryWallet?: string;
|
|
55
|
+
amountUsd?: number;
|
|
56
|
+
amountLamports?: number;
|
|
57
|
+
amountTokens?: number;
|
|
58
|
+
protocolFee?: number;
|
|
59
|
+
platformFee?: number;
|
|
60
|
+
tipAmount?: number;
|
|
61
|
+
currency?: 'SOL' | 'USDC' | 'USDT' | 'USD';
|
|
62
|
+
tokenType?: string;
|
|
63
|
+
orderRef?: string;
|
|
64
|
+
status?: string;
|
|
65
|
+
active?: boolean;
|
|
66
|
+
feeBps?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface WebhookConfig {
|
|
70
|
+
/** Webhook endpoint URL */
|
|
71
|
+
url: string;
|
|
72
|
+
/** Events to subscribe to */
|
|
73
|
+
events: WebhookEventType[];
|
|
74
|
+
/** Filter by merchant ID (optional) */
|
|
75
|
+
merchantId?: number;
|
|
76
|
+
/** Filter by platform ID (optional) */
|
|
77
|
+
platformId?: number;
|
|
78
|
+
/** Secret for HMAC signature verification */
|
|
79
|
+
secret: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type EventHandler = (event: WebhookEvent) => void | Promise<void>;
|
|
83
|
+
|
|
84
|
+
// ==================== SIGNATURE VERIFICATION ====================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Verify webhook signature (for webhook receivers)
|
|
88
|
+
*/
|
|
89
|
+
export async function verifyWebhookSignature(
|
|
90
|
+
payload: string,
|
|
91
|
+
signature: string,
|
|
92
|
+
secret: string
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
const encoder = new TextEncoder();
|
|
95
|
+
const key = await crypto.subtle.importKey(
|
|
96
|
+
'raw',
|
|
97
|
+
encoder.encode(secret),
|
|
98
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
99
|
+
false,
|
|
100
|
+
['sign']
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const expectedSig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload));
|
|
104
|
+
const expectedHex = Array.from(new Uint8Array(expectedSig))
|
|
105
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
106
|
+
.join('');
|
|
107
|
+
|
|
108
|
+
return signature === expectedHex;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create webhook signature (for webhook senders)
|
|
113
|
+
*/
|
|
114
|
+
export async function createWebhookSignature(payload: string, secret: string): Promise<string> {
|
|
115
|
+
const encoder = new TextEncoder();
|
|
116
|
+
const key = await crypto.subtle.importKey(
|
|
117
|
+
'raw',
|
|
118
|
+
encoder.encode(secret),
|
|
119
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
120
|
+
false,
|
|
121
|
+
['sign']
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payload));
|
|
125
|
+
return Array.from(new Uint8Array(signature))
|
|
126
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
127
|
+
.join('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ==================== EVENT LISTENER ====================
|
|
131
|
+
|
|
132
|
+
export interface EventListenerOptions {
|
|
133
|
+
/** Solana network */
|
|
134
|
+
network: Network;
|
|
135
|
+
/** Custom RPC endpoint */
|
|
136
|
+
rpcEndpoint?: string;
|
|
137
|
+
/** Poll interval in ms (default: 5000) */
|
|
138
|
+
pollIntervalMs?: number;
|
|
139
|
+
/** Filter by merchant ID */
|
|
140
|
+
merchantId?: number;
|
|
141
|
+
/** Filter by platform ID */
|
|
142
|
+
platformId?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* On-chain event listener
|
|
147
|
+
* Polls the blockchain for new transactions and emits events
|
|
148
|
+
*/
|
|
149
|
+
export class EventListener {
|
|
150
|
+
private connection: Connection;
|
|
151
|
+
private programId: PublicKey;
|
|
152
|
+
private options: EventListenerOptions;
|
|
153
|
+
private handlers: Map<WebhookEventType, EventHandler[]> = new Map();
|
|
154
|
+
private lastSignature: string | null = null;
|
|
155
|
+
private isRunning = false;
|
|
156
|
+
private pollInterval: NodeJS.Timeout | null = null;
|
|
157
|
+
private coder: BorshCoder;
|
|
158
|
+
|
|
159
|
+
constructor(options: EventListenerOptions) {
|
|
160
|
+
this.options = options;
|
|
161
|
+
this.programId = PROGRAM_ID;
|
|
162
|
+
this.coder = new BorshCoder(IDL as any);
|
|
163
|
+
|
|
164
|
+
const endpoint = options.rpcEndpoint ||
|
|
165
|
+
(options.network === 'mainnet-beta'
|
|
166
|
+
? 'https://api.mainnet-beta.solana.com'
|
|
167
|
+
: 'https://api.devnet.solana.com');
|
|
168
|
+
|
|
169
|
+
this.connection = new Connection(endpoint, 'confirmed');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Subscribe to an event type
|
|
174
|
+
*/
|
|
175
|
+
on(eventType: WebhookEventType, handler: EventHandler): void {
|
|
176
|
+
const handlers = this.handlers.get(eventType) || [];
|
|
177
|
+
handlers.push(handler);
|
|
178
|
+
this.handlers.set(eventType, handlers);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Unsubscribe from an event type
|
|
183
|
+
*/
|
|
184
|
+
off(eventType: WebhookEventType, handler: EventHandler): void {
|
|
185
|
+
const handlers = this.handlers.get(eventType) || [];
|
|
186
|
+
const index = handlers.indexOf(handler);
|
|
187
|
+
if (index > -1) {
|
|
188
|
+
handlers.splice(index, 1);
|
|
189
|
+
this.handlers.set(eventType, handlers);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Subscribe to all events
|
|
195
|
+
*/
|
|
196
|
+
onAny(handler: EventHandler): void {
|
|
197
|
+
const allTypes: WebhookEventType[] = [
|
|
198
|
+
'protocol.initialized',
|
|
199
|
+
'protocol.updated',
|
|
200
|
+
'platform.created',
|
|
201
|
+
'platform.updated',
|
|
202
|
+
'merchant.created',
|
|
203
|
+
'merchant.updated',
|
|
204
|
+
'payment.completed',
|
|
205
|
+
'payment.pending',
|
|
206
|
+
'link.created',
|
|
207
|
+
'link.created.usd',
|
|
208
|
+
'link.paid',
|
|
209
|
+
'link.paid.usd',
|
|
210
|
+
'link.cancelled',
|
|
211
|
+
'link.closed',
|
|
212
|
+
'link.expired',
|
|
213
|
+
];
|
|
214
|
+
allTypes.forEach(type => this.on(type, handler));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Start listening for events
|
|
219
|
+
*/
|
|
220
|
+
start(): void {
|
|
221
|
+
if (this.isRunning) return;
|
|
222
|
+
this.isRunning = true;
|
|
223
|
+
|
|
224
|
+
this.poll();
|
|
225
|
+
this.pollInterval = setInterval(
|
|
226
|
+
() => this.poll(),
|
|
227
|
+
this.options.pollIntervalMs || 5000
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Stop listening for events
|
|
233
|
+
*/
|
|
234
|
+
stop(): void {
|
|
235
|
+
this.isRunning = false;
|
|
236
|
+
if (this.pollInterval) {
|
|
237
|
+
clearInterval(this.pollInterval);
|
|
238
|
+
this.pollInterval = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Poll for new transactions
|
|
244
|
+
*/
|
|
245
|
+
private async poll(): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
const signatures = await this.connection.getSignaturesForAddress(
|
|
248
|
+
this.programId,
|
|
249
|
+
{
|
|
250
|
+
limit: 20,
|
|
251
|
+
until: this.lastSignature || undefined,
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (signatures.length === 0) return;
|
|
256
|
+
|
|
257
|
+
// Update last signature
|
|
258
|
+
this.lastSignature = signatures[0].signature;
|
|
259
|
+
|
|
260
|
+
// Process transactions in order (oldest first)
|
|
261
|
+
for (const sig of signatures.reverse()) {
|
|
262
|
+
await this.processTransaction(sig.signature);
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('Error polling for events:', error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Process a transaction and emit decoded events
|
|
271
|
+
*/
|
|
272
|
+
private async processTransaction(signature: string): Promise<void> {
|
|
273
|
+
try {
|
|
274
|
+
const tx = await this.connection.getParsedTransaction(signature, {
|
|
275
|
+
maxSupportedTransactionVersion: 0,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (!tx?.meta?.logMessages) return;
|
|
279
|
+
|
|
280
|
+
const events: WebhookEvent[] = [];
|
|
281
|
+
const timestamp = tx.blockTime ? tx.blockTime * 1000 : Date.now();
|
|
282
|
+
|
|
283
|
+
// Decode Anchor events from "Program data:" log entries
|
|
284
|
+
for (const log of tx.meta.logMessages) {
|
|
285
|
+
if (!log.startsWith('Program data: ')) continue;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const base64Data = log.slice('Program data: '.length);
|
|
289
|
+
const decoded = this.coder.events.decode(base64Data);
|
|
290
|
+
if (!decoded) continue;
|
|
291
|
+
|
|
292
|
+
const webhookType = this.anchorEventToWebhookType(decoded.name);
|
|
293
|
+
if (!webhookType) continue;
|
|
294
|
+
|
|
295
|
+
events.push({
|
|
296
|
+
id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
297
|
+
type: webhookType,
|
|
298
|
+
timestamp,
|
|
299
|
+
transactionSignature: signature,
|
|
300
|
+
data: this.mapEventData(decoded.name, decoded.data),
|
|
301
|
+
});
|
|
302
|
+
} catch {
|
|
303
|
+
// Skip events that can't be decoded
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const event of events) {
|
|
308
|
+
// Apply filters
|
|
309
|
+
if (this.options.merchantId && event.data.merchantId !== this.options.merchantId) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (this.options.platformId && event.data.platformId !== this.options.platformId) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Emit to handlers
|
|
317
|
+
await this.emit(event);
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
console.error('Error processing transaction:', error);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Map Anchor event name to webhook event type */
|
|
325
|
+
private anchorEventToWebhookType(name: string): WebhookEventType | null {
|
|
326
|
+
const map: Record<string, WebhookEventType> = {
|
|
327
|
+
'ProtocolInitialized': 'protocol.initialized',
|
|
328
|
+
'ProtocolUpdated': 'protocol.updated',
|
|
329
|
+
'PlatformCreated': 'platform.created',
|
|
330
|
+
'PlatformUpdated': 'platform.updated',
|
|
331
|
+
'MerchantCreated': 'merchant.created',
|
|
332
|
+
'MerchantUpdated': 'merchant.updated',
|
|
333
|
+
'PaymentProcessed': 'payment.completed',
|
|
334
|
+
'LinkCreated': 'link.created',
|
|
335
|
+
'LinkCreatedUsd': 'link.created.usd',
|
|
336
|
+
'LinkPaid': 'link.paid',
|
|
337
|
+
'LinkPaidUsd': 'link.paid.usd',
|
|
338
|
+
'LinkCancelled': 'link.cancelled',
|
|
339
|
+
'LinkClosed': 'link.closed',
|
|
340
|
+
};
|
|
341
|
+
return map[name] || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Map decoded Anchor event data to WebhookEventData */
|
|
345
|
+
private mapEventData(eventName: string, data: any): WebhookEventData {
|
|
346
|
+
const result: WebhookEventData = {};
|
|
347
|
+
|
|
348
|
+
// ID field: link ID for Link* events, platform/merchant ID for others
|
|
349
|
+
if (data.id !== undefined) {
|
|
350
|
+
if (eventName.startsWith('Link')) {
|
|
351
|
+
result.linkId = Number(data.id);
|
|
352
|
+
} else if (eventName.startsWith('Platform')) {
|
|
353
|
+
result.platformId = Number(data.id);
|
|
354
|
+
} else if (eventName.startsWith('Merchant')) {
|
|
355
|
+
result.merchantId = Number(data.id);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Common entity IDs (Anchor camelCases snake_case fields)
|
|
360
|
+
if (data.merchantId !== undefined) result.merchantId = Number(data.merchantId);
|
|
361
|
+
if (data.platformId !== undefined) result.platformId = Number(data.platformId);
|
|
362
|
+
|
|
363
|
+
// Wallet addresses
|
|
364
|
+
if (data.payer) result.payerWallet = data.payer.toString();
|
|
365
|
+
if (data.wallet) result.merchantWallet = data.wallet.toString();
|
|
366
|
+
if (data.admin) result.platformWallet = data.admin.toString();
|
|
367
|
+
if (data.treasury) result.treasuryWallet = data.treasury.toString();
|
|
368
|
+
|
|
369
|
+
// Amounts
|
|
370
|
+
if (data.amount !== undefined) result.amountLamports = Number(data.amount);
|
|
371
|
+
if (data.amountUsd !== undefined) result.amountUsd = Number(data.amountUsd) / 1_000_000;
|
|
372
|
+
if (data.usdAmount !== undefined) result.amountUsd = Number(data.usdAmount) / 1_000_000;
|
|
373
|
+
if (data.merchantAmount !== undefined) result.amountTokens = Number(data.merchantAmount);
|
|
374
|
+
|
|
375
|
+
// Fees
|
|
376
|
+
if (data.protocolFee !== undefined) result.protocolFee = Number(data.protocolFee);
|
|
377
|
+
if (data.platformFee !== undefined) result.platformFee = Number(data.platformFee);
|
|
378
|
+
if (data.feeBps !== undefined) result.feeBps = Number(data.feeBps);
|
|
379
|
+
|
|
380
|
+
// Tips
|
|
381
|
+
if (data.tip !== undefined) result.tipAmount = Number(data.tip);
|
|
382
|
+
if (data.tipUsd !== undefined) result.tipAmount = Number(data.tipUsd) / 1_000_000;
|
|
383
|
+
|
|
384
|
+
// Order reference
|
|
385
|
+
if (data.orderRef !== undefined) result.orderRef = data.orderRef.toString();
|
|
386
|
+
|
|
387
|
+
// Token type (Anchor deserializes enums as { variant: {} })
|
|
388
|
+
if (data.tokenType !== undefined) {
|
|
389
|
+
const variant = typeof data.tokenType === 'object' ? Object.keys(data.tokenType)[0] : String(data.tokenType);
|
|
390
|
+
result.tokenType = variant;
|
|
391
|
+
if (variant === 'sol' || variant === 'Sol') result.currency = 'SOL';
|
|
392
|
+
else if (variant === 'usdc' || variant === 'Usdc') result.currency = 'USDC';
|
|
393
|
+
else if (variant === 'usdt' || variant === 'Usdt') result.currency = 'USDT';
|
|
394
|
+
else if (variant === 'usd' || variant === 'Usd') result.currency = 'USD';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Status (same enum pattern)
|
|
398
|
+
if (data.status !== undefined) {
|
|
399
|
+
result.status = typeof data.status === 'object' ? Object.keys(data.status)[0] : String(data.status);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Active flag
|
|
403
|
+
if (data.active !== undefined) result.active = Boolean(data.active);
|
|
404
|
+
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Emit event to handlers
|
|
410
|
+
*/
|
|
411
|
+
private async emit(event: WebhookEvent): Promise<void> {
|
|
412
|
+
const handlers = this.handlers.get(event.type) || [];
|
|
413
|
+
|
|
414
|
+
for (const handler of handlers) {
|
|
415
|
+
try {
|
|
416
|
+
await handler(event);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.error('Error in event handler:', error);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ==================== WEBHOOK SENDER ====================
|
|
425
|
+
|
|
426
|
+
export interface WebhookSenderOptions {
|
|
427
|
+
/** Retry failed webhooks */
|
|
428
|
+
retryCount?: number;
|
|
429
|
+
/** Retry delay in ms */
|
|
430
|
+
retryDelayMs?: number;
|
|
431
|
+
/** Timeout in ms */
|
|
432
|
+
timeoutMs?: number;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Send webhooks to registered endpoints
|
|
437
|
+
*/
|
|
438
|
+
export class WebhookSender {
|
|
439
|
+
private options: WebhookSenderOptions;
|
|
440
|
+
|
|
441
|
+
constructor(options: WebhookSenderOptions = {}) {
|
|
442
|
+
this.options = {
|
|
443
|
+
retryCount: options.retryCount ?? 3,
|
|
444
|
+
retryDelayMs: options.retryDelayMs ?? 1000,
|
|
445
|
+
timeoutMs: options.timeoutMs ?? 10000,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Send webhook to endpoint
|
|
451
|
+
*/
|
|
452
|
+
async send(config: WebhookConfig, event: WebhookEvent): Promise<boolean> {
|
|
453
|
+
const payload = JSON.stringify(event);
|
|
454
|
+
const signature = await createWebhookSignature(payload, config.secret);
|
|
455
|
+
|
|
456
|
+
for (let attempt = 0; attempt <= this.options.retryCount!; attempt++) {
|
|
457
|
+
try {
|
|
458
|
+
const controller = new AbortController();
|
|
459
|
+
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
|
|
460
|
+
|
|
461
|
+
const response = await fetch(config.url, {
|
|
462
|
+
method: 'POST',
|
|
463
|
+
headers: {
|
|
464
|
+
'Content-Type': 'application/json',
|
|
465
|
+
'X-Rotate-Signature': signature,
|
|
466
|
+
'X-Rotate-Event': event.type,
|
|
467
|
+
'X-Rotate-Timestamp': event.timestamp.toString(),
|
|
468
|
+
'X-Rotate-Delivery-Attempt': attempt.toString(),
|
|
469
|
+
},
|
|
470
|
+
body: payload,
|
|
471
|
+
signal: controller.signal,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
clearTimeout(timeout);
|
|
475
|
+
|
|
476
|
+
if (response.ok) {
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Retry on 5xx errors
|
|
481
|
+
if (response.status >= 500 && attempt < this.options.retryCount!) {
|
|
482
|
+
await this.delay(this.options.retryDelayMs! * Math.pow(2, attempt));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return false;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (attempt < this.options.retryCount!) {
|
|
489
|
+
await this.delay(this.options.retryDelayMs! * Math.pow(2, attempt));
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private delay(ms: number): Promise<void> {
|
|
500
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ==================== EXPORTS ====================
|
|
505
|
+
|
|
506
|
+
export default EventListener;
|