Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ npm test # Run tests

## Requirements

- Node.js 18+
- Node.js 20.12.0+
- A [HitPay account](https://dashboard.hit-pay.com/register) (sandbox available)

## License
Expand Down
8 changes: 2 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"author": "HitPay",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
"node": ">=20.12.0"
},
"dependencies": {
"@inquirer/prompts": "^8.3.2",
Expand Down
18 changes: 11 additions & 7 deletions src/commands/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ import * as output from '../lib/output.js';
import { handleError } from '../lib/errors.js';
import { createWebhookServer } from '../listen/server.js';
import { createTunnel } from '../listen/tunnel.js';
import { forwardEvent } from '../listen/forwarder.js';
import { forwardEvent, ForwardResult } from '../listen/forwarder.js';
import { printEvent } from '../listen/printer.js';

export function registerListen(program: Command): void {
program
.command('listen')
.description('Listen for webhook events and forward to your local server')
.requiredOption('--forward-to <url>', 'Local URL to forward events to (e.g. http://localhost:3000/webhook)')
.option('--events <types>', 'Comma-separated event types to listen for')
.option('--events <events...>', 'Events to listen for')
.option('--port <port>', 'Local port for the webhook receiver', '0')
.action(async (opts, cmd) => {
try {
const globalOpts = cmd.parent?.opts() || {};
const client = createClient({ environment: globalOpts.env });
const forwardUrl = opts.forwardTo;
const eventFilter = opts.events ? opts.events.split(',').map((e: string) => e.trim()) : undefined;
const eventFilter = opts.events ? opts.events.map((e: string) => e.trim()) : undefined;

let webhookId: string | undefined;
let tunnelRef: { close: () => Promise<void> } | undefined;
Expand Down Expand Up @@ -61,13 +61,15 @@ export function registerListen(program: Command): void {
// 1. Start local HTTP server
const { start, stop, server } = createWebhookServer(async (event) => {
// Filter events if specified
const eventType = String(event.headers['hitpay-event-type'] || event.body.event || '');
if (eventFilter && !eventFilter.some((f: string) => eventType.includes(f))) {
const eventObject = String(event.headers['hitpay-event-object'] || '');
const eventType = String(event.headers['hitpay-event-type'] || '');
const eventName = `${eventObject}.${eventType}`;
if (eventFilter && !eventFilter.includes(eventName)) {
return;
}

// Forward to user's dev server
let result;
let result: ForwardResult | undefined;
try {
result = await forwardEvent(event, forwardUrl);
} catch (err) {
Expand All @@ -76,6 +78,7 @@ export function registerListen(program: Command): void {

// Print event in terminal
printEvent(event, result);
return result;
});

serverRef = { stop };
Expand All @@ -89,10 +92,11 @@ export function registerListen(program: Command): void {
// 3. Register webhook with HitPay
spinner.text = 'Registering webhook endpoint...';
const webhookBody: Record<string, unknown> = {
name: `Testing Webhook for ${forwardUrl}`,
url: tunnel.url,
};
if (eventFilter) {
webhookBody.events = eventFilter;
webhookBody.event_types = eventFilter;
}

const webhook = await client.post<WebhookEventResponse>('/v1/webhook-events', webhookBody);
Expand Down
10 changes: 10 additions & 0 deletions src/listen/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WebhookEvent } from './server.js';
export interface ForwardResult {
status: number;
ok: boolean;
message: unknown;
duration: number;
}

Expand All @@ -24,9 +25,18 @@ export async function forwardEvent(
body: JSON.stringify(event.body),
});

const text = await res.text();
let message: unknown = text;
try {
message = JSON.parse(text);
} catch {
// Ignore and leave as text
}

return {
status: res.status,
ok: res.ok,
message,
duration: Date.now() - start,
};
}
6 changes: 4 additions & 2 deletions src/listen/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import type { ForwardResult } from './forwarder.js';

export function printEvent(event: WebhookEvent, forwardResult?: ForwardResult): void {
const time = event.receivedAt.toISOString().split('T')[1].replace('Z', '');
const eventType = String(event.headers['hitpay-event-type'] || event.body.event || 'unknown');
const eventType = String(event.headers['hitpay-event-type'] || 'unknown');
const eventObject = String(event.headers['hitpay-event-object'] || 'unknown');
const eventName = `${eventObject}.${eventType}`;
const status = String(event.body.status || '');

// Event header
console.log(
chalk.dim(time) +
' ' +
chalk.bold.cyan(eventType) +
chalk.bold.cyan(eventName) +
(status ? ` [${statusColor(status)}]` : ''),
);

Expand Down
25 changes: 18 additions & 7 deletions src/listen/server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { ForwardResult } from './forwarder';

export interface WebhookEvent {
headers: Record<string, string | string[] | undefined>;
body: Record<string, unknown>;
receivedAt: Date;
}

export type EventHandler = (event: WebhookEvent) => void;
export type EventHandler = (event: WebhookEvent) => Promise<ForwardResult | undefined> | any;

export function createWebhookServer(onEvent: EventHandler): {
server: ReturnType<typeof createHttpServer>;
Expand Down Expand Up @@ -35,17 +36,27 @@ export function createWebhookServer(onEvent: EventHandler): {
body = Object.fromEntries(new URLSearchParams(rawBody));
}

onEvent({
const result = await onEvent({
headers: req.headers as Record<string, string | string[] | undefined>,
body,
receivedAt: new Date(),
});

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch {
res.writeHead(500);
res.end('Internal error');

if (!result || typeof result !== 'object' || typeof result.status !== 'number') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Event ignored by filter' }));
return;
}

const payload = typeof result.message === 'string' ? result.message : JSON.stringify(result.message);
res.writeHead(result.status, { 'Content-Type': 'application/json' });
res.end(payload);
} catch (err) {
if (!res.headersSent) {
res.writeHead(500);
res.end('Internal error');
}
}
});

Expand Down
6 changes: 4 additions & 2 deletions tests/listen/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,17 @@ describe('Webhook server', () => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'hitpay-event-type': 'charge.created',
'hitpay-event-object': 'charge',
'hitpay-event-type': 'created',
},
body: JSON.stringify({ amount: '100', currency: 'SGD', status: 'completed' }),
});

expect(res.status).toBe(200);
expect(events).toHaveLength(1);
expect(events[0].body.amount).toBe('100');
expect(events[0].headers['hitpay-event-type']).toBe('charge.created');
expect(events[0].headers['hitpay-event-object']).toBe('charge');
expect(events[0].headers['hitpay-event-type']).toBe('created');
});

it('handles form-encoded POST body', async () => {
Expand Down