Skip to content

Commit 2a76e31

Browse files
committed
Release v1.1.0 — async hooks in forward mode
1 parent 9880d83 commit 2a76e31

10 files changed

Lines changed: 232 additions & 70 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "posthook",
3-
"version": "1.0.2",
3+
"version": "1.1.0",
44
"description": "Posthook CLI - Manage projects and receive webhook deliveries locally",
55
"type": "module",
66
"bin": {

src/commands/__tests__/listen.test.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('createForwardHandler', () => {
109109

110110
it('sends positive ack on successful forward', async () => {
111111
mockForwarder.forward.mockResolvedValue({
112-
success: true,
112+
kind: 'sync_success',
113113
statusCode: 200,
114114
duration: 50,
115115
});
@@ -126,9 +126,51 @@ describe('createForwardHandler', () => {
126126
expect(mockClient.sendAck).toHaveBeenCalledWith('hook-123', true);
127127
});
128128

129+
it('sends async ack when forward target returns 202', async () => {
130+
mockForwarder.forward.mockResolvedValue({
131+
kind: 'async_accepted',
132+
asyncTimeout: 600,
133+
duration: 50,
134+
});
135+
136+
const handler = createForwardHandler(
137+
() => mockClient as any,
138+
mockForwarder as any,
139+
false
140+
);
141+
142+
await handler(createHook({ ackUrl: 'https://api.posthook.io/ack', nackUrl: 'https://api.posthook.io/nack' }), emptyQueue);
143+
144+
expect(mockClient.sendAck).toHaveBeenCalledWith('hook-123', true, null, {
145+
async: true,
146+
timeout: 600,
147+
});
148+
});
149+
150+
it('rejects 202 when callback URLs are unavailable', async () => {
151+
mockForwarder.forward.mockResolvedValue({
152+
kind: 'async_accepted',
153+
duration: 50,
154+
});
155+
156+
const handler = createForwardHandler(
157+
() => mockClient as any,
158+
mockForwarder as any,
159+
false
160+
);
161+
162+
await handler(createHook({ ackUrl: undefined, nackUrl: undefined }), emptyQueue);
163+
164+
expect(mockClient.sendAck).toHaveBeenCalledWith(
165+
'hook-123',
166+
false,
167+
'Forward target returned 202 but async callback URLs are unavailable'
168+
);
169+
});
170+
129171
it('sends negative ack on failed forward', async () => {
130172
mockForwarder.forward.mockResolvedValue({
131-
success: false,
173+
kind: 'failure',
132174
statusCode: 500,
133175
error: 'Internal Server Error',
134176
duration: 100,
@@ -147,8 +189,7 @@ describe('createForwardHandler', () => {
147189

148190
it('does not send ack when request was aborted', async () => {
149191
mockForwarder.forward.mockResolvedValue({
150-
success: false,
151-
aborted: true,
192+
kind: 'aborted',
152193
error: 'Request aborted',
153194
duration: 5000,
154195
});
@@ -164,11 +205,11 @@ describe('createForwardHandler', () => {
164205
expect(mockClient.sendAck).not.toHaveBeenCalled();
165206
});
166207

167-
it('sends negative ack with default message when error is undefined', async () => {
208+
it('sends negative ack with returned error message', async () => {
168209
mockForwarder.forward.mockResolvedValue({
169-
success: false,
210+
kind: 'failure',
170211
statusCode: 502,
171-
error: undefined,
212+
error: 'Forward failed',
172213
duration: 100,
173214
});
174215

@@ -185,7 +226,7 @@ describe('createForwardHandler', () => {
185226

186227
it('logs verbose output when verbose flag is true', async () => {
187228
mockForwarder.forward.mockResolvedValue({
188-
success: true,
229+
kind: 'sync_success',
189230
statusCode: 200,
190231
duration: 50,
191232
});
@@ -210,7 +251,7 @@ describe('createForwardHandler', () => {
210251

211252
it('logs Posthook-* headers in verbose mode when present', async () => {
212253
mockForwarder.forward.mockResolvedValue({
213-
success: true,
254+
kind: 'sync_success',
214255
statusCode: 200,
215256
duration: 50,
216257
});
@@ -245,7 +286,7 @@ describe('createForwardHandler', () => {
245286

246287
it('omits Posthook-* headers in verbose mode when absent', async () => {
247288
mockForwarder.forward.mockResolvedValue({
248-
success: true,
289+
kind: 'sync_success',
249290
statusCode: 200,
250291
duration: 50,
251292
});
@@ -271,7 +312,7 @@ describe('createForwardHandler', () => {
271312

272313
it('handles hook without forwardRequest in verbose mode gracefully', async () => {
273314
mockForwarder.forward.mockResolvedValue({
274-
success: true,
315+
kind: 'sync_success',
275316
statusCode: 200,
276317
duration: 50,
277318
});

src/commands/listen.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -202,22 +202,35 @@ export function createForwardHandler(
202202
// Forward to the local endpoint
203203
const result = await forwarder.forward(hook);
204204

205-
// If request was aborted (server timeout), don't send ack - server already moved on
206-
if (result.aborted) {
207-
console.log(` ${chalk.yellow('⏱')} ${result.error} (${result.duration}ms)`);
208-
return;
209-
}
210-
211205
const client = getClient();
212-
if (result.success) {
213-
// Success - send positive ack
214-
client.sendAck(hook.id, true);
215-
console.log(` ${chalk.green('✓')} ${result.statusCode} (${result.duration}ms)`);
216-
} else {
217-
// Failed - send negative ack so it will retry
218-
client.sendAck(hook.id, false, result.error || 'Forward failed');
219-
const statusInfo = result.statusCode ? `${result.statusCode} ` : '';
220-
console.log(` ${chalk.red('✗')} ${statusInfo}${result.error} (${result.duration}ms)`);
206+
switch (result.kind) {
207+
case 'aborted':
208+
console.log(` ${chalk.yellow('⏱')} ${result.error} (${result.duration}ms)`);
209+
return;
210+
211+
case 'async_accepted':
212+
if (!hook.ackUrl || !hook.nackUrl) {
213+
client.sendAck(hook.id, false, 'Forward target returned 202 but async callback URLs are unavailable');
214+
console.log(` ${chalk.red('✗')} 202 Async callbacks unavailable (${result.duration}ms)`);
215+
return;
216+
}
217+
218+
client.sendAck(hook.id, true, null, {
219+
async: true,
220+
timeout: result.asyncTimeout,
221+
});
222+
console.log(` ${chalk.cyan('↻')} 202 accepted for async processing (${result.duration}ms)`);
223+
return;
224+
225+
case 'sync_success':
226+
client.sendAck(hook.id, true);
227+
console.log(` ${chalk.green('✓')} ${result.statusCode} (${result.duration}ms)`);
228+
return;
229+
230+
case 'failure':
231+
client.sendAck(hook.id, false, result.error);
232+
console.log(` ${chalk.red('✗')} ${result.statusCode ? `${result.statusCode} ` : ''}${result.error} (${result.duration}ms)`);
233+
return;
221234
}
222235
};
223236
}

src/lib/__tests__/api.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe('api', () => {
2626

2727
mockFetch.mockResolvedValue({
2828
ok: true,
29+
status: 200,
30+
statusText: 'OK',
2931
json: () => Promise.resolve(ticketResponse),
3032
});
3133

@@ -86,6 +88,19 @@ describe('api', () => {
8688
await expect(getTicket('https://api.test.io', 'key')).rejects.not.toThrow(AuthenticationError);
8789
});
8890

91+
it('rejects unexpected 201 ticket responses', async () => {
92+
mockFetch.mockResolvedValue({
93+
ok: true,
94+
status: 201,
95+
statusText: 'Created',
96+
text: () => Promise.resolve('created unexpectedly'),
97+
});
98+
99+
const { getTicket } = await import('../api.js');
100+
101+
await expect(getTicket('https://api.test.io', 'key')).rejects.toThrow(/API error: 201 Created/);
102+
});
103+
89104
it('throws timeout error when request aborted', async () => {
90105
const abortError = new Error('Aborted');
91106
abortError.name = 'AbortError';
@@ -120,6 +135,8 @@ describe('api', () => {
120135
const ticketResponse = { data: { ticket: 'test', url: 'wss://test', expiresAt: '' } };
121136
mockFetch.mockResolvedValue({
122137
ok: true,
138+
status: 200,
139+
statusText: 'OK',
123140
json: () => Promise.resolve(ticketResponse),
124141
});
125142

@@ -145,6 +162,8 @@ describe('api', () => {
145162
};
146163
mockFetch.mockResolvedValue({
147164
ok: true,
165+
status: 200,
166+
statusText: 'OK',
148167
json: () => Promise.resolve(ticketResponse),
149168
});
150169

0 commit comments

Comments
 (0)