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/src/cli.ts CHANGED
@@ -1,255 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from 'node:child_process'
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
- const schemes: Record<string, SignatureScheme<any, any>> = {
22
- github,
23
- shopify,
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: WebhookEventSummary,
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 call = summarizeEvent(event)
81
- const success = call.status < 400
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
- if (call.route === null || !ignored.has(call.route)) {
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('webhook_request_total', {
120
- description: 'HTTP webhook requests by route template and status'
200
+ total: meter.createCounter(total.name, {
201
+ description: total.description
121
202
  }),
122
- duration: meter.createHistogram('webhook_request_duration_ms', {
123
- unit: 'ms',
124
- description: 'HTTP webhook handler duration by route template and status'
203
+ duration: meter.createHistogram(duration.name, {
204
+ unit: duration.unit,
205
+ description: duration.description
125
206
  })
126
207
  }
127
208
  }