hono-webhooks 0.4.0 → 0.5.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 +30 -5
- package/dist/cli-commands.d.ts +1 -0
- package/dist/cli.js +5674 -233
- package/dist/otel.d.ts +51 -2
- package/dist/otel.js +47 -9
- package/package.json +4 -2
- package/src/cli-commands.ts +270 -0
- package/src/cli.ts +4 -253
- package/src/otel.ts +90 -9
package/src/cli.ts
CHANGED
|
@@ -1,255 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync } from 'node:fs'
|
|
4
|
-
import { resolve } from 'node:path'
|
|
5
|
-
import { pathToFileURL } from 'node:url'
|
|
6
|
-
import { parseArgs } from 'node:util'
|
|
7
|
-
import {
|
|
8
|
-
createWebhooks,
|
|
9
|
-
describeWebhooks,
|
|
10
|
-
type Webhook,
|
|
11
|
-
type WebhooksApp
|
|
12
|
-
} from './index'
|
|
13
|
-
import {
|
|
14
|
-
github,
|
|
15
|
-
type SignatureScheme,
|
|
16
|
-
shopify,
|
|
17
|
-
stripe,
|
|
18
|
-
svix
|
|
19
|
-
} from './signatures'
|
|
2
|
+
import { cli } from './cli-commands'
|
|
20
3
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
stripe,
|
|
25
|
-
svix
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const HELP = `Usage: hono-webhooks <module> <command> [options]
|
|
29
|
-
|
|
30
|
-
<module> exports a createWebhooks() app or an array of webhooks, as default
|
|
31
|
-
or named export.
|
|
32
|
-
|
|
33
|
-
Commands:
|
|
34
|
-
list List the webhooks
|
|
35
|
-
schema <path> Print the JSON Schemas of the webhooks at <path>
|
|
36
|
-
test <method> <path> Send a request through the webhooks app
|
|
37
|
-
|
|
38
|
-
Options:
|
|
39
|
-
-r, --preload <module> Import a module first (e.g. to load secrets), repeatable
|
|
40
|
-
|
|
41
|
-
Options for test:
|
|
42
|
-
-b, --body <text> Request body (JSON sets content-type: application/json)
|
|
43
|
-
-q, --query <params> Query string, e.g. "a=1&b=2"
|
|
44
|
-
-H, --header <k: v> Request header, repeatable
|
|
45
|
-
-s, --sign <scheme> Sign the body: ${Object.keys(schemes).join(', ')}
|
|
46
|
-
-e, --secret-env <name> Environment variable holding the signing secret
|
|
47
|
-
|
|
48
|
-
Examples:
|
|
49
|
-
hono-webhooks ./src/webhooks.ts list
|
|
50
|
-
hono-webhooks ./src/webhooks.ts schema /orders/:id
|
|
51
|
-
hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
|
|
52
|
-
hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
|
|
53
|
-
-r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
const { values, positionals } = parseArgs({
|
|
57
|
-
allowPositionals: true,
|
|
58
|
-
options: {
|
|
59
|
-
help: { type: 'boolean', short: 'h' },
|
|
60
|
-
preload: { type: 'string', short: 'r', multiple: true },
|
|
61
|
-
body: { type: 'string', short: 'b' },
|
|
62
|
-
query: { type: 'string', short: 'q' },
|
|
63
|
-
header: { type: 'string', short: 'H', multiple: true },
|
|
64
|
-
sign: { type: 'string', short: 's' },
|
|
65
|
-
'secret-env': { type: 'string', short: 'e' }
|
|
66
|
-
}
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
const [modulePath, command, ...args] = positionals
|
|
70
|
-
|
|
71
|
-
function fail(message: string): never {
|
|
72
|
-
console.error(message)
|
|
73
|
-
process.exit(1)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** A file path relative to the cwd, or a package name. */
|
|
77
|
-
const importModule = (specifier: string) =>
|
|
78
|
-
import(
|
|
79
|
-
existsSync(resolve(specifier))
|
|
80
|
-
? pathToFileURL(resolve(specifier)).href
|
|
81
|
-
: specifier
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
function isWebhooksApp(value: unknown): value is WebhooksApp {
|
|
85
|
-
return (
|
|
86
|
-
typeof value === 'object' &&
|
|
87
|
-
value !== null &&
|
|
88
|
-
Array.isArray((value as WebhooksApp).webhooks) &&
|
|
89
|
-
typeof (value as WebhooksApp).request === 'function'
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function isWebhookArray(value: unknown): value is Webhook[] {
|
|
94
|
-
return (
|
|
95
|
-
Array.isArray(value) &&
|
|
96
|
-
value.length > 0 &&
|
|
97
|
-
value.every(
|
|
98
|
-
(item) =>
|
|
99
|
-
typeof item?.config?.path === 'string' &&
|
|
100
|
-
typeof item?.handler === 'function'
|
|
101
|
-
)
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function loadApp(path: string): Promise<WebhooksApp> {
|
|
106
|
-
const mod = await importModule(path)
|
|
107
|
-
const candidates = [mod.default, ...Object.values(mod)]
|
|
108
|
-
const app = candidates.find(isWebhooksApp)
|
|
109
|
-
if (app) return app
|
|
110
|
-
const webhooks = candidates.find(isWebhookArray)
|
|
111
|
-
if (webhooks) return createWebhooks(webhooks)
|
|
112
|
-
return fail(
|
|
113
|
-
`${path} exports neither a createWebhooks() app nor an array of webhooks`
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Errors Node gives for TypeScript it can't load but Bun can. */
|
|
118
|
-
const BUN_ONLY_ERRORS = new Set([
|
|
119
|
-
'ERR_MODULE_NOT_FOUND',
|
|
120
|
-
'ERR_UNKNOWN_FILE_EXTENSION',
|
|
121
|
-
'ERR_UNSUPPORTED_DIR_IMPORT',
|
|
122
|
-
'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX',
|
|
123
|
-
'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
|
|
124
|
-
])
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Under Node, a Bun project's TypeScript (extensionless imports, for one)
|
|
128
|
-
* fails to load. Rerun the same command under Bun when it is installed.
|
|
129
|
-
*/
|
|
130
|
-
function rerunUnderBun(error: unknown): never {
|
|
131
|
-
const code = (error as { code?: string }).code
|
|
132
|
-
if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code)) throw error
|
|
133
|
-
console.error(`Node can't load the module (${code}), trying Bun`)
|
|
134
|
-
const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
|
|
135
|
-
if (run.error) {
|
|
136
|
-
fail(
|
|
137
|
-
'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
process.exit(run.status ?? 1)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function signatureHeaders(rawBody: string) {
|
|
144
|
-
if (!values.sign) return {}
|
|
145
|
-
const scheme =
|
|
146
|
-
schemes[values.sign] ??
|
|
147
|
-
fail(
|
|
148
|
-
`Unknown scheme "${values.sign}", use one of: ${Object.keys(schemes).join(', ')}`
|
|
149
|
-
)
|
|
150
|
-
const name = values['secret-env'] ?? fail('--sign needs --secret-env <name>')
|
|
151
|
-
const secret = process.env[name] ?? fail(`${name} is not set`)
|
|
152
|
-
return scheme.sign(rawBody, secret)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function main() {
|
|
156
|
-
if (values.help || !modulePath || !command) {
|
|
157
|
-
console.log(HELP)
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
let app: WebhooksApp
|
|
161
|
-
try {
|
|
162
|
-
for (const preload of values.preload ?? []) await importModule(preload)
|
|
163
|
-
app = await loadApp(modulePath)
|
|
164
|
-
} catch (error) {
|
|
165
|
-
rerunUnderBun(error)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (command === 'list') {
|
|
169
|
-
for (const { config } of app.webhooks) {
|
|
170
|
-
const validates = [
|
|
171
|
-
...(['params', 'query', 'headers', 'body'] as const).filter(
|
|
172
|
-
(key) => config[key]
|
|
173
|
-
),
|
|
174
|
-
...(config.verify ? ['signature'] : [])
|
|
175
|
-
].join(', ')
|
|
176
|
-
console.log(
|
|
177
|
-
`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
|
|
178
|
-
)
|
|
179
|
-
if (validates) console.log(` validates ${validates}`)
|
|
180
|
-
}
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (command === 'schema') {
|
|
185
|
-
const [path] = args
|
|
186
|
-
const descriptions = describeWebhooks(app)
|
|
187
|
-
const indexes = app.webhooks.flatMap((w, i) =>
|
|
188
|
-
w.config.path === path ? [i] : []
|
|
189
|
-
)
|
|
190
|
-
if (!indexes.length) fail(`No webhook at ${path}`)
|
|
191
|
-
for (const i of indexes) {
|
|
192
|
-
const { config } = app.webhooks[i]!
|
|
193
|
-
console.log(`${config.method} ${config.path}`)
|
|
194
|
-
for (const part of [
|
|
195
|
-
'params',
|
|
196
|
-
'query',
|
|
197
|
-
'headers',
|
|
198
|
-
'body',
|
|
199
|
-
'response'
|
|
200
|
-
] as const) {
|
|
201
|
-
if (!config[part]) continue
|
|
202
|
-
const schema = descriptions[i]!.jsonSchema[part]
|
|
203
|
-
console.log(
|
|
204
|
-
` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (command === 'test') {
|
|
212
|
-
const [method, path] = args
|
|
213
|
-
if (!method || !path) fail('Usage: test <method> <path>')
|
|
214
|
-
const headers = new Headers()
|
|
215
|
-
for (const header of values.header ?? []) {
|
|
216
|
-
const at = header.indexOf(':')
|
|
217
|
-
if (at < 1) fail(`Header must look like "name: value", got "${header}"`)
|
|
218
|
-
headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
|
|
219
|
-
}
|
|
220
|
-
if (values.body && !headers.has('content-type')) {
|
|
221
|
-
try {
|
|
222
|
-
JSON.parse(values.body)
|
|
223
|
-
headers.set('content-type', 'application/json')
|
|
224
|
-
} catch {
|
|
225
|
-
headers.set('content-type', 'text/plain')
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
for (const [name, value] of Object.entries(
|
|
229
|
-
await signatureHeaders(values.body ?? '')
|
|
230
|
-
)) {
|
|
231
|
-
headers.set(name, value)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const url = `${path.startsWith('/') ? path : `/${path}`}${values.query ? `?${values.query}` : ''}`
|
|
235
|
-
const start = performance.now()
|
|
236
|
-
const res = await app.request(url, {
|
|
237
|
-
method: method.toUpperCase(),
|
|
238
|
-
headers,
|
|
239
|
-
body: values.body
|
|
240
|
-
})
|
|
241
|
-
const text = await res.text()
|
|
242
|
-
let body = text
|
|
243
|
-
try {
|
|
244
|
-
body = JSON.stringify(JSON.parse(text), null, 2)
|
|
245
|
-
} catch {}
|
|
246
|
-
console.log(`${res.status} (${(performance.now() - start).toFixed(1)}ms)`)
|
|
247
|
-
if (body) console.log(body)
|
|
248
|
-
if (!res.ok) process.exitCode = 1
|
|
249
|
-
return
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
fail(`Unknown command: ${command}\n\n${HELP}`)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
await main()
|
|
4
|
+
// hanzio/cli exits 2 on bad arguments; this CLI has always exited 1.
|
|
5
|
+
const code = await cli.run()
|
|
6
|
+
process.exitCode = code === 2 ? 1 : code
|
package/src/otel.ts
CHANGED
|
@@ -18,6 +18,54 @@ import {
|
|
|
18
18
|
/** The `route` label for 404s, which match no webhook. */
|
|
19
19
|
export const UNMATCHED_ROUTE = '__unmatched__'
|
|
20
20
|
|
|
21
|
+
/** A metric `otelWebhooks` records, described for a metric catalog. */
|
|
22
|
+
export interface WebhookMetricDefinition {
|
|
23
|
+
name: string
|
|
24
|
+
kind: 'counter' | 'histogram'
|
|
25
|
+
unit?: string
|
|
26
|
+
description: string
|
|
27
|
+
/** Attribute key to its closed set of values, or `'string'` when open. */
|
|
28
|
+
attributes: Record<string, readonly string[] | 'string'>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const metricAttributes = {
|
|
32
|
+
route: 'string',
|
|
33
|
+
status: ['success', 'failed']
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The metrics `otelWebhooks` records, as data: list them in a metric
|
|
38
|
+
* catalog without creating a second recorder.
|
|
39
|
+
*/
|
|
40
|
+
export const webhookMetrics = [
|
|
41
|
+
{
|
|
42
|
+
name: 'webhook_request_total',
|
|
43
|
+
kind: 'counter',
|
|
44
|
+
description: 'HTTP webhook requests by route template and status',
|
|
45
|
+
attributes: metricAttributes
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'webhook_request_duration_ms',
|
|
49
|
+
kind: 'histogram',
|
|
50
|
+
unit: 'ms',
|
|
51
|
+
description: 'HTTP webhook handler duration by route template and status',
|
|
52
|
+
attributes: metricAttributes
|
|
53
|
+
}
|
|
54
|
+
] as const satisfies readonly WebhookMetricDefinition[]
|
|
55
|
+
|
|
56
|
+
/** `summarizeEvent(event)` plus what a completion log needs. */
|
|
57
|
+
export interface WebhookCall extends WebhookEventSummary {
|
|
58
|
+
/** `error` for 5xx, `warn` for 4xx, else `info`. */
|
|
59
|
+
level: 'info' | 'warn' | 'error'
|
|
60
|
+
outcome: 'success' | 'failed' | 'validation_failed'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `console` fits, as does any logger with these three methods. */
|
|
64
|
+
export type WebhookLogger = Record<
|
|
65
|
+
WebhookCall['level'],
|
|
66
|
+
(message: string, fields: Record<string, string | number>) => void
|
|
67
|
+
>
|
|
68
|
+
|
|
21
69
|
export interface OtelWebhooksOptions<E extends Env = any> {
|
|
22
70
|
/** @default trace.getTracer('webhooks') */
|
|
23
71
|
tracer?: Tracer
|
|
@@ -31,9 +79,19 @@ export interface OtelWebhooksOptions<E extends Env = any> {
|
|
|
31
79
|
* `onEvent`'s.
|
|
32
80
|
*/
|
|
33
81
|
onWebhookCall?: (
|
|
34
|
-
call:
|
|
82
|
+
call: WebhookCall,
|
|
35
83
|
event: WebhookEvent<E>
|
|
36
84
|
) => void | Promise<void>
|
|
85
|
+
/**
|
|
86
|
+
* Logs one `webhook.completed` event per request at `call.level`, with
|
|
87
|
+
* `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
|
|
88
|
+
* and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
|
|
89
|
+
* request's span, so the logger can attach to it.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* logger: () => console
|
|
93
|
+
*/
|
|
94
|
+
logger?: (span: Span) => WebhookLogger
|
|
37
95
|
}
|
|
38
96
|
|
|
39
97
|
/**
|
|
@@ -77,8 +135,17 @@ export function otelWebhooks<E extends Env = any>(
|
|
|
77
135
|
)
|
|
78
136
|
},
|
|
79
137
|
onEvent: (event) => {
|
|
80
|
-
const
|
|
81
|
-
const success =
|
|
138
|
+
const summary = summarizeEvent(event)
|
|
139
|
+
const success = summary.status < 400
|
|
140
|
+
const call: WebhookCall = {
|
|
141
|
+
...summary,
|
|
142
|
+
level: summary.status >= 500 ? 'error' : success ? 'info' : 'warn',
|
|
143
|
+
outcome: summary.validation
|
|
144
|
+
? 'validation_failed'
|
|
145
|
+
: success
|
|
146
|
+
? 'success'
|
|
147
|
+
: 'failed'
|
|
148
|
+
}
|
|
82
149
|
|
|
83
150
|
const span = spans.get(event.c)
|
|
84
151
|
span?.setAttributes({
|
|
@@ -96,7 +163,8 @@ export function otelWebhooks<E extends Env = any>(
|
|
|
96
163
|
code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
|
|
97
164
|
})
|
|
98
165
|
|
|
99
|
-
|
|
166
|
+
const isIgnored = call.route !== null && ignored.has(call.route)
|
|
167
|
+
if (!isIgnored) {
|
|
100
168
|
// Created lazily so a meter provider registered after import still applies.
|
|
101
169
|
instruments ??= createInstruments(
|
|
102
170
|
options.meter ?? metrics.getMeter('webhooks')
|
|
@@ -109,19 +177,32 @@ export function otelWebhooks<E extends Env = any>(
|
|
|
109
177
|
instruments.duration.record(event.durationMs, attributes)
|
|
110
178
|
}
|
|
111
179
|
|
|
180
|
+
if (span && options.logger && call.route !== null && !isIgnored) {
|
|
181
|
+
options.logger(span)[call.level]('webhook.completed', {
|
|
182
|
+
event: 'webhook.completed',
|
|
183
|
+
route: call.route,
|
|
184
|
+
method: call.method,
|
|
185
|
+
status_code: call.status,
|
|
186
|
+
duration_ms: call.durationMs,
|
|
187
|
+
status: call.outcome,
|
|
188
|
+
...(call.error && { error_type: call.error.name })
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
112
192
|
return options.onWebhookCall?.(call, event)
|
|
113
193
|
}
|
|
114
194
|
}
|
|
115
195
|
}
|
|
116
196
|
|
|
117
197
|
function createInstruments(meter: Meter) {
|
|
198
|
+
const [total, duration] = webhookMetrics
|
|
118
199
|
return {
|
|
119
|
-
total: meter.createCounter(
|
|
120
|
-
description:
|
|
200
|
+
total: meter.createCounter(total.name, {
|
|
201
|
+
description: total.description
|
|
121
202
|
}),
|
|
122
|
-
duration: meter.createHistogram(
|
|
123
|
-
unit:
|
|
124
|
-
description:
|
|
203
|
+
duration: meter.createHistogram(duration.name, {
|
|
204
|
+
unit: duration.unit,
|
|
205
|
+
description: duration.description
|
|
125
206
|
})
|
|
126
207
|
}
|
|
127
208
|
}
|