@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/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 };