@usehenri/webhooks 0.0.0 → 1.2.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/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +48 -0
- package/module.js +8 -0
- package/package.json +51 -10
- package/src/address.js +280 -0
- package/src/config.js +85 -0
- package/src/deliver.js +271 -0
- package/src/errors.js +107 -0
- package/src/job.js +49 -0
- package/src/module.js +368 -0
- package/src/secrets.js +240 -0
- package/src/signature.js +330 -0
- package/src/store/index.js +41 -0
- package/src/store/mongo.js +229 -0
- package/src/store/schema.js +230 -0
- package/src/store/sql.js +450 -0
- package/src/webhooks.js +1219 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { duration } = require('@usehenri/jobs');
|
|
2
|
+
|
|
3
|
+
const { coded } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `webhooks` block of `config/<env>.json`, with every default filled in.
|
|
7
|
+
*
|
|
8
|
+
* The durations are read by the queue's own parser, so `'10s'` means the
|
|
9
|
+
* same thing in `webhooks.timeout` as it does in `jobs.timeout`.
|
|
10
|
+
*
|
|
11
|
+
* The retry policy is the queue's, and only its numbers are different:
|
|
12
|
+
* eight attempts with a base of ten seconds tripling up to six hours is
|
|
13
|
+
* roughly three days of trying, which is what a receiver that is down for a
|
|
14
|
+
* weekend needs and what Stripe settled on. A delivery that runs out of
|
|
15
|
+
* attempts is in the dead letter queue, not gone.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
allowHttp: false,
|
|
20
|
+
allowPrivate: false,
|
|
21
|
+
backoff: { base: '10s', factor: 3, jitter: 0.2, max: '6h' },
|
|
22
|
+
install: true,
|
|
23
|
+
maxAttempts: 8,
|
|
24
|
+
maxFanout: 1000,
|
|
25
|
+
queue: 'webhooks',
|
|
26
|
+
store: 'default',
|
|
27
|
+
table: 'henri_webhooks',
|
|
28
|
+
timeout: '10s',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks a table name before it is written into every statement
|
|
33
|
+
*
|
|
34
|
+
* The table name is configuration and not request input, but an application
|
|
35
|
+
* may set any key from the environment, and a name that is not a plain
|
|
36
|
+
* identifier gives a syntax error deep in a query instead of a sentence
|
|
37
|
+
* here.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} value The name
|
|
40
|
+
* @returns {string} The name
|
|
41
|
+
* @throws {Error} HENRI_CONFIG_INVALID when it is not a plain identifier
|
|
42
|
+
*/
|
|
43
|
+
const table = (value) => {
|
|
44
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) {
|
|
45
|
+
throw coded(
|
|
46
|
+
'HENRI_CONFIG_INVALID',
|
|
47
|
+
`@usehenri/webhooks: invalid table name "${value}": letters, digits and underscores only`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The webhooks configuration of an application
|
|
56
|
+
*
|
|
57
|
+
* @param {object} [config={}] The `webhooks` block
|
|
58
|
+
* @returns {object} The configuration, with the defaults filled in
|
|
59
|
+
* @throws {Error} When a duration or the table name is invalid
|
|
60
|
+
*/
|
|
61
|
+
const normalize = (config = {}) => {
|
|
62
|
+
const value = config || {};
|
|
63
|
+
const backoff = { ...DEFAULTS.backoff, ...(value.backoff || {}) };
|
|
64
|
+
const name = table(value.table || DEFAULTS.table);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
allowHttp: value.allowHttp === true,
|
|
68
|
+
allowPrivate: value.allowPrivate === true,
|
|
69
|
+
backoff: {
|
|
70
|
+
base: duration(backoff.base, 10000),
|
|
71
|
+
factor: Number(backoff.factor) || DEFAULTS.backoff.factor,
|
|
72
|
+
jitter: Math.min(Math.max(Number(backoff.jitter) || 0, 0), 1),
|
|
73
|
+
max: duration(backoff.max, 21600000),
|
|
74
|
+
},
|
|
75
|
+
install: value.install !== false,
|
|
76
|
+
maxAttempts: Math.max(1, Number(value.maxAttempts) || DEFAULTS.maxAttempts),
|
|
77
|
+
maxFanout: Math.max(1, Number(value.maxFanout) || DEFAULTS.maxFanout),
|
|
78
|
+
queue: value.queue || DEFAULTS.queue,
|
|
79
|
+
store: value.store || DEFAULTS.store,
|
|
80
|
+
tables: { endpoints: name },
|
|
81
|
+
timeout: duration(value.timeout, 10000),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
module.exports = { DEFAULTS, normalize, table };
|
package/src/deliver.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const debug = require('debug')('henri:webhooks');
|
|
4
|
+
|
|
5
|
+
const { WebhookDeliveryError, WebhookError } = require('./errors');
|
|
6
|
+
const { check } = require('./address');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One HTTP request to a receiver, and what to make of the answer.
|
|
10
|
+
*
|
|
11
|
+
* Everything here assumes the receiver is hostile until it has proven
|
|
12
|
+
* otherwise, which it never quite does:
|
|
13
|
+
*
|
|
14
|
+
* - the socket connects to the address `address.js` checked, and to no
|
|
15
|
+
* other: the agent is handed a `lookup` that answers that one address, so
|
|
16
|
+
* the name cannot resolve to something else between the check and the
|
|
17
|
+
* connection. TLS still validates against the *name*, not the address, so
|
|
18
|
+
* pinning costs no certificate warning;
|
|
19
|
+
* - a redirect is not followed. A `3xx` is a receiver choosing the next
|
|
20
|
+
* url, after henri checked the one it was given, and following it would
|
|
21
|
+
* hand back the hole the address check just closed. Stripe treats a
|
|
22
|
+
* redirect as a failure too; henri goes one step further and does not
|
|
23
|
+
* retry it, because the fix is a registration change, not time;
|
|
24
|
+
* - the answer is read up to `maxBytes` and then the socket is destroyed,
|
|
25
|
+
* so a receiver that streams forever holds a runner for the length of one
|
|
26
|
+
* timeout and not a byte more. Nothing in the answer is parsed: it is
|
|
27
|
+
* kept as a short excerpt, for the operator to read;
|
|
28
|
+
* - one deadline covers the whole exchange -- resolution, connection,
|
|
29
|
+
* headers and body -- because three separate timeouts add up to a wait
|
|
30
|
+
* nobody configured.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** How much of an answer is read, and kept for the operator */
|
|
34
|
+
const MAX_BYTES = 65536;
|
|
35
|
+
|
|
36
|
+
/** How much of the answer is shown in the queue's error message */
|
|
37
|
+
const EXCERPT = 500;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The statuses a delivery may not be retried on, with what they mean
|
|
41
|
+
*
|
|
42
|
+
* Everything else is retried, `4xx` included: a `401` is a token that was
|
|
43
|
+
* rotated a minute ago, a `404` is a deploy that is half done, and the
|
|
44
|
+
* queue's backoff is patient enough to outlive both. The two exceptions are
|
|
45
|
+
* the answers where waiting cannot help:
|
|
46
|
+
*
|
|
47
|
+
* - `3xx`: the url is wrong. It has to be re-registered.
|
|
48
|
+
* - `410 Gone`: the receiver is saying "stop". henri stops, and disables
|
|
49
|
+
* the endpoint so nothing else is queued for it.
|
|
50
|
+
*/
|
|
51
|
+
const FINAL = {
|
|
52
|
+
gone: 410,
|
|
53
|
+
redirect: [300, 399],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether an answer means the delivery arrived
|
|
58
|
+
*
|
|
59
|
+
* @param {number} status The HTTP status
|
|
60
|
+
* @returns {boolean} Whether it is a 2xx
|
|
61
|
+
*/
|
|
62
|
+
const accepted = (status) => status >= 200 && status < 300;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A short, single-line excerpt of what a receiver answered
|
|
66
|
+
*
|
|
67
|
+
* @param {string} body The answer body
|
|
68
|
+
* @returns {string} The excerpt
|
|
69
|
+
*/
|
|
70
|
+
const excerpt = (body) =>
|
|
71
|
+
String(body || '')
|
|
72
|
+
.replace(/\s+/gu, ' ')
|
|
73
|
+
.trim()
|
|
74
|
+
.slice(0, EXCERPT);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sends one request, to one address, with one deadline
|
|
78
|
+
*
|
|
79
|
+
* @param {object} options Options
|
|
80
|
+
* @param {URL} options.url The url
|
|
81
|
+
* @param {string} options.address The address the socket connects to
|
|
82
|
+
* @param {number} options.family 4 or 6
|
|
83
|
+
* @param {string} options.body The body, as it was signed
|
|
84
|
+
* @param {object} options.headers The headers to send
|
|
85
|
+
* @param {number} options.timeout The deadline, in milliseconds
|
|
86
|
+
* @param {number} [options.maxBytes=MAX_BYTES] How much answer to read
|
|
87
|
+
* @returns {Promise<object>} `{ status, headers, body, duration }`
|
|
88
|
+
* @throws {WebhookError} HENRI_WEBHOOK_TIMEOUT, or the socket's own error
|
|
89
|
+
*/
|
|
90
|
+
const send = ({
|
|
91
|
+
address,
|
|
92
|
+
body,
|
|
93
|
+
family,
|
|
94
|
+
headers,
|
|
95
|
+
maxBytes = MAX_BYTES,
|
|
96
|
+
timeout,
|
|
97
|
+
url,
|
|
98
|
+
}) =>
|
|
99
|
+
new Promise((resolve, reject) => {
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
102
|
+
const payload = Buffer.from(body, 'utf8');
|
|
103
|
+
let settled = false;
|
|
104
|
+
let timer = null;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Answers once, whichever of the deadline and the socket got there first
|
|
108
|
+
*
|
|
109
|
+
* @param {?Error} error What went wrong, or nothing
|
|
110
|
+
* @param {object} [answer] The answer
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
const done = (error, answer) => {
|
|
114
|
+
if (settled) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
settled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
|
|
121
|
+
if (error) {
|
|
122
|
+
reject(error);
|
|
123
|
+
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
resolve({ ...answer, duration: Date.now() - started });
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const request = client.request(
|
|
131
|
+
url,
|
|
132
|
+
{
|
|
133
|
+
agent: false,
|
|
134
|
+
headers: {
|
|
135
|
+
...headers,
|
|
136
|
+
'content-length': String(payload.length),
|
|
137
|
+
},
|
|
138
|
+
// The name resolved once, was checked, and is not asked again: this
|
|
139
|
+
// is what closes the window between the check and the connection.
|
|
140
|
+
// `autoSelectFamily` (on by default since Node 20) asks with
|
|
141
|
+
// `all: true` and wants a list back, so both shapes are answered
|
|
142
|
+
lookup: (hostname, settings, callback) =>
|
|
143
|
+
settings && settings.all
|
|
144
|
+
? callback(null, [{ address, family }])
|
|
145
|
+
: callback(null, address, family),
|
|
146
|
+
method: 'POST',
|
|
147
|
+
},
|
|
148
|
+
(response) => {
|
|
149
|
+
const chunks = [];
|
|
150
|
+
let size = 0;
|
|
151
|
+
|
|
152
|
+
response.on('data', (chunk) => {
|
|
153
|
+
size += chunk.length;
|
|
154
|
+
chunks.push(size > maxBytes ? chunk.subarray(0, maxBytes) : chunk);
|
|
155
|
+
|
|
156
|
+
if (size >= maxBytes) {
|
|
157
|
+
response.destroy();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Hands back what was read, however the answer ended
|
|
163
|
+
*
|
|
164
|
+
* @returns {void}
|
|
165
|
+
*/
|
|
166
|
+
const finish = () =>
|
|
167
|
+
done(null, {
|
|
168
|
+
body: Buffer.concat(chunks).toString('utf8').slice(0, maxBytes),
|
|
169
|
+
headers: response.headers,
|
|
170
|
+
status: response.statusCode,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
response.on('end', finish);
|
|
174
|
+
// A body cut short by maxBytes emits `close`, never `end`
|
|
175
|
+
response.on('close', finish);
|
|
176
|
+
response.on('error', (error) => done(error));
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
timer = setTimeout(() => {
|
|
181
|
+
request.destroy();
|
|
182
|
+
done(
|
|
183
|
+
new WebhookError(
|
|
184
|
+
'HENRI_WEBHOOK_TIMEOUT',
|
|
185
|
+
`the receiver did not answer within ${timeout}ms`,
|
|
186
|
+
{ timeout }
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
}, timeout);
|
|
190
|
+
|
|
191
|
+
request.on('error', (error) => done(error));
|
|
192
|
+
request.end(payload);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Delivers a signed body to a url, and says what the answer means
|
|
197
|
+
*
|
|
198
|
+
* @param {object} options Options
|
|
199
|
+
* @param {string} options.url Where to deliver
|
|
200
|
+
* @param {string} options.body The body, as it was signed
|
|
201
|
+
* @param {object} options.headers The headers, signature included
|
|
202
|
+
* @param {number} options.timeout The deadline, in milliseconds
|
|
203
|
+
* @param {boolean} [options.allowHttp] Allow a plaintext url
|
|
204
|
+
* @param {boolean} [options.allowPrivate] Allow a private address
|
|
205
|
+
* @param {Function} [options.lookup] A `dns.promises.lookup` stand-in
|
|
206
|
+
* @param {number} [options.maxBytes] How much answer to read
|
|
207
|
+
* @returns {Promise<object>} `{ status, address, duration, body }`
|
|
208
|
+
* @throws {WebhookAddressError} When the address may not be reached
|
|
209
|
+
* @throws {WebhookDeliveryError} When the receiver refused the delivery
|
|
210
|
+
*/
|
|
211
|
+
const deliver = async (options) => {
|
|
212
|
+
const { address, family, url } = await check(options.url, options);
|
|
213
|
+
|
|
214
|
+
debug('POST %s (%s)', url.href, address);
|
|
215
|
+
|
|
216
|
+
const answer = await send({
|
|
217
|
+
address,
|
|
218
|
+
body: options.body,
|
|
219
|
+
family,
|
|
220
|
+
headers: options.headers,
|
|
221
|
+
maxBytes: options.maxBytes,
|
|
222
|
+
timeout: options.timeout,
|
|
223
|
+
url,
|
|
224
|
+
});
|
|
225
|
+
const result = {
|
|
226
|
+
address,
|
|
227
|
+
body: excerpt(answer.body),
|
|
228
|
+
duration: answer.duration,
|
|
229
|
+
status: answer.status,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (accepted(answer.status)) {
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const [from, to] = FINAL.redirect;
|
|
237
|
+
const location = answer.headers.location;
|
|
238
|
+
|
|
239
|
+
if (answer.status >= from && answer.status <= to) {
|
|
240
|
+
throw new WebhookDeliveryError(
|
|
241
|
+
`the receiver answered ${answer.status} and pointed at ${location || 'nowhere'}; a delivery does not follow a redirect`,
|
|
242
|
+
{
|
|
243
|
+
...result,
|
|
244
|
+
hint: 'Register the url the redirect names: henri webhooks:update <id> --url <url>',
|
|
245
|
+
retryable: false,
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (answer.status === FINAL.gone) {
|
|
251
|
+
throw new WebhookDeliveryError(
|
|
252
|
+
'the receiver answered 410 Gone: it is asking not to be sent to again',
|
|
253
|
+
{ ...result, gone: true, retryable: false }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
throw new WebhookDeliveryError(
|
|
258
|
+
`the receiver answered ${answer.status}${result.body ? `: ${result.body}` : ''}`,
|
|
259
|
+
result
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
EXCERPT,
|
|
265
|
+
FINAL,
|
|
266
|
+
MAX_BYTES,
|
|
267
|
+
accepted,
|
|
268
|
+
deliver,
|
|
269
|
+
excerpt,
|
|
270
|
+
send,
|
|
271
|
+
};
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The errors `@usehenri/webhooks` throws.
|
|
3
|
+
*
|
|
4
|
+
* Every one of them carries a `code` from henri's own catalogue
|
|
5
|
+
* (`@usehenri/core/error-codes.json`), so an application branches on the
|
|
6
|
+
* reason instead of matching a message. A code is a string and nothing
|
|
7
|
+
* more: raising one imports nothing.
|
|
8
|
+
*
|
|
9
|
+
* `retryable` is the other half. A delivery is a job, so a failure that the
|
|
10
|
+
* queue should try again says so by staying silent (the default), and one
|
|
11
|
+
* that a retry cannot fix -- a redirect, an address that must not be
|
|
12
|
+
* reached, a receiver that answered `410 Gone` -- carries
|
|
13
|
+
* `retryable: false`, which buries the job on the spot instead of spending
|
|
14
|
+
* eight attempts to learn the same thing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base error of the package
|
|
19
|
+
*
|
|
20
|
+
* @class WebhookError
|
|
21
|
+
* @extends {Error}
|
|
22
|
+
*/
|
|
23
|
+
class WebhookError extends Error {
|
|
24
|
+
/**
|
|
25
|
+
* Creates an instance of WebhookError.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} code A henri error code (ex: HENRI_WEBHOOK_UNKNOWN)
|
|
28
|
+
* @param {string} message What went wrong
|
|
29
|
+
* @param {object} [options={}] `cause`, `retryable` and anything to carry
|
|
30
|
+
* @memberof WebhookError
|
|
31
|
+
*/
|
|
32
|
+
constructor(code, message, options = {}) {
|
|
33
|
+
const { cause, ...rest } = options;
|
|
34
|
+
|
|
35
|
+
super(message, cause ? { cause } : undefined);
|
|
36
|
+
|
|
37
|
+
this.name = 'WebhookError';
|
|
38
|
+
this.code = code;
|
|
39
|
+
Object.assign(this, rest);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A url a delivery must not open
|
|
45
|
+
*
|
|
46
|
+
* The address is checked when the request is made and not when the endpoint
|
|
47
|
+
* was registered, because DNS answers differently later: the name that
|
|
48
|
+
* resolved to a public address yesterday resolves to 169.254.169.254 today,
|
|
49
|
+
* and only the resolution that the request itself does is evidence.
|
|
50
|
+
*
|
|
51
|
+
* @class WebhookAddressError
|
|
52
|
+
* @extends {WebhookError}
|
|
53
|
+
*/
|
|
54
|
+
class WebhookAddressError extends WebhookError {
|
|
55
|
+
/**
|
|
56
|
+
* Creates an instance of WebhookAddressError.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} message What is wrong with the address
|
|
59
|
+
* @param {object} [options={}] `url`, `address` and the usual options
|
|
60
|
+
* @memberof WebhookAddressError
|
|
61
|
+
*/
|
|
62
|
+
constructor(message, options = {}) {
|
|
63
|
+
super('HENRI_WEBHOOK_ADDRESS_REFUSED', message, {
|
|
64
|
+
retryable: false,
|
|
65
|
+
...options,
|
|
66
|
+
});
|
|
67
|
+
this.name = 'WebhookAddressError';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A receiver that answered something other than a 2xx
|
|
73
|
+
*
|
|
74
|
+
* @class WebhookDeliveryError
|
|
75
|
+
* @extends {WebhookError}
|
|
76
|
+
*/
|
|
77
|
+
class WebhookDeliveryError extends WebhookError {
|
|
78
|
+
/**
|
|
79
|
+
* Creates an instance of WebhookDeliveryError.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} message What the receiver said
|
|
82
|
+
* @param {object} [options={}] `status`, `retryable` and the usual options
|
|
83
|
+
* @memberof WebhookDeliveryError
|
|
84
|
+
*/
|
|
85
|
+
constructor(message, options = {}) {
|
|
86
|
+
super('HENRI_WEBHOOK_DELIVERY_FAILED', message, options);
|
|
87
|
+
this.name = 'WebhookDeliveryError';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An Error carrying one of henri's error codes
|
|
93
|
+
*
|
|
94
|
+
* @param {string} code The henri error code
|
|
95
|
+
* @param {string} message What went wrong
|
|
96
|
+
* @param {object} [rest={}] Anything else to carry (`hint`, `retryable`)
|
|
97
|
+
* @returns {Error} The error to throw
|
|
98
|
+
*/
|
|
99
|
+
const coded = (code, message, rest = {}) =>
|
|
100
|
+
Object.assign(new Error(message), { code, ...rest });
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
WebhookAddressError,
|
|
104
|
+
WebhookDeliveryError,
|
|
105
|
+
WebhookError,
|
|
106
|
+
coded,
|
|
107
|
+
};
|
package/src/job.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const { DELIVERY_JOB } = require('./webhooks');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The job that performs one delivery.
|
|
5
|
+
*
|
|
6
|
+
* A delivery is a job and nothing else, which is the whole design: the
|
|
7
|
+
* retries, the exponential backoff, the dead letter queue, the visibility
|
|
8
|
+
* (`henri jobs:list --queue webhooks`, `henri jobs:dead`,
|
|
9
|
+
* `henri jobs:show <id>`) and the recovery of a runner that died mid-flight
|
|
10
|
+
* are the queue's, already written, already covered on four databases. A
|
|
11
|
+
* second delivery mechanism would be a second, worse copy of it.
|
|
12
|
+
*
|
|
13
|
+
* The job's own timeout is the HTTP deadline plus a margin, on purpose: the
|
|
14
|
+
* request's deadline should be what fires, because it says
|
|
15
|
+
* "the receiver did not answer within 10000ms" and the job's timeout says
|
|
16
|
+
* only that the attempt ran long. The margin is the backstop for the case
|
|
17
|
+
* where something outside the request hangs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How much longer than the request the job itself may take */
|
|
21
|
+
const MARGIN = 5000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The definition of the delivery job, for `henri.jobs.define()`
|
|
25
|
+
*
|
|
26
|
+
* @param {object} webhooks A started Webhooks
|
|
27
|
+
* @returns {object} `{ name, definition }`
|
|
28
|
+
*/
|
|
29
|
+
const definition = (webhooks) => ({
|
|
30
|
+
definition: {
|
|
31
|
+
backoff: webhooks.config.backoff,
|
|
32
|
+
maxAttempts: webhooks.config.maxAttempts,
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Delivers one webhook
|
|
36
|
+
*
|
|
37
|
+
* @param {object} args `{ body, endpoint, event, id }`
|
|
38
|
+
* @param {object} context The job context
|
|
39
|
+
* @returns {Promise<object>} What happened
|
|
40
|
+
*/
|
|
41
|
+
perform: (args, context) => webhooks.perform(args, context),
|
|
42
|
+
|
|
43
|
+
queue: webhooks.config.queue,
|
|
44
|
+
timeout: webhooks.config.timeout + MARGIN,
|
|
45
|
+
},
|
|
46
|
+
name: DELIVERY_JOB,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
module.exports = { MARGIN, definition };
|