hono-webhooks 0.4.1 → 0.5.1

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/dist/otel.d.ts CHANGED
@@ -1,8 +1,47 @@
1
- import { type Meter, type Tracer } from '@opentelemetry/api';
1
+ import { type Meter, type Span, type Tracer } from '@opentelemetry/api';
2
2
  import type { Env } from 'hono';
3
3
  import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
4
4
  /** The `route` label for 404s, which match no webhook. */
5
5
  export declare const UNMATCHED_ROUTE = "__unmatched__";
6
+ /** A metric `otelWebhooks` records, described for a metric catalog. */
7
+ export interface WebhookMetricDefinition {
8
+ name: string;
9
+ kind: 'counter' | 'histogram';
10
+ unit?: string;
11
+ description: string;
12
+ /** Attribute key to its closed set of values, or `'string'` when open. */
13
+ attributes: Record<string, readonly string[] | 'string'>;
14
+ }
15
+ /**
16
+ * The metrics `otelWebhooks` records, as data: list them in a metric
17
+ * catalog without creating a second recorder.
18
+ */
19
+ export declare const webhookMetrics: readonly [{
20
+ readonly name: 'webhook_request_total';
21
+ readonly kind: 'counter';
22
+ readonly description: 'HTTP webhook requests by route template and status';
23
+ readonly attributes: {
24
+ readonly route: 'string';
25
+ readonly status: readonly ['success', 'failed'];
26
+ };
27
+ }, {
28
+ readonly name: 'webhook_request_duration_ms';
29
+ readonly kind: 'histogram';
30
+ readonly unit: 'ms';
31
+ readonly description: 'HTTP webhook handler duration by route template and status';
32
+ readonly attributes: {
33
+ readonly route: 'string';
34
+ readonly status: readonly ['success', 'failed'];
35
+ };
36
+ }];
37
+ /** `summarizeEvent(event)` plus what a completion log needs. */
38
+ export interface WebhookCall extends WebhookEventSummary {
39
+ /** `error` for 5xx, `warn` for 4xx, else `info`. */
40
+ level: 'info' | 'warn' | 'error';
41
+ outcome: 'success' | 'failed' | 'validation_failed';
42
+ }
43
+ /** `console` fits, as does any logger with these three methods. */
44
+ export type WebhookLogger = Record<WebhookCall['level'], (message: string, fields: Record<string, string | number>) => void>;
6
45
  export interface OtelWebhooksOptions<E extends Env = any> {
7
46
  /** @default trace.getTracer('webhooks') */
8
47
  tracer?: Tracer;
@@ -15,7 +54,17 @@ export interface OtelWebhooksOptions<E extends Env = any> {
15
54
  * still active: log it, store it. A returned promise is handled like
16
55
  * `onEvent`'s.
17
56
  */
18
- onWebhookCall?: (call: WebhookEventSummary, event: WebhookEvent<E>) => void | Promise<void>;
57
+ onWebhookCall?: (call: WebhookCall, event: WebhookEvent<E>) => void | Promise<void>;
58
+ /**
59
+ * Logs one `webhook.completed` event per request at `call.level`, with
60
+ * `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
61
+ * and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
62
+ * request's span, so the logger can attach to it.
63
+ *
64
+ * @example
65
+ * logger: () => console
66
+ */
67
+ logger?: (span: Span) => WebhookLogger;
19
68
  }
20
69
  /**
21
70
  * `around` and `onEvent` that put each request in a server span and record
package/dist/otel.js CHANGED
@@ -9,6 +9,25 @@ import {
9
9
  summarizeEvent
10
10
  } from "hono-webhooks";
11
11
  var UNMATCHED_ROUTE = "__unmatched__";
12
+ var metricAttributes = {
13
+ route: "string",
14
+ status: ["success", "failed"]
15
+ };
16
+ var webhookMetrics = [
17
+ {
18
+ name: "webhook_request_total",
19
+ kind: "counter",
20
+ description: "HTTP webhook requests by route template and status",
21
+ attributes: metricAttributes
22
+ },
23
+ {
24
+ name: "webhook_request_duration_ms",
25
+ kind: "histogram",
26
+ unit: "ms",
27
+ description: "HTTP webhook handler duration by route template and status",
28
+ attributes: metricAttributes
29
+ }
30
+ ];
12
31
  function otelWebhooks(options = {}) {
13
32
  const tracer = options.tracer ?? trace.getTracer("webhooks");
14
33
  const ignored = new Set(options.ignoreRoutes);
@@ -30,8 +49,13 @@ function otelWebhooks(options = {}) {
30
49
  });
31
50
  },
32
51
  onEvent: (event) => {
33
- const call = summarizeEvent(event);
34
- const success = call.status < 400;
52
+ const summary = summarizeEvent(event);
53
+ const success = summary.status < 400;
54
+ const call = {
55
+ ...summary,
56
+ level: summary.status >= 500 ? "error" : success ? "info" : "warn",
57
+ outcome: summary.validation ? "validation_failed" : success ? "success" : "failed"
58
+ };
35
59
  const span = spans.get(event.c);
36
60
  span?.setAttributes({
37
61
  "http.status_code": call.status,
@@ -47,7 +71,8 @@ function otelWebhooks(options = {}) {
47
71
  span?.setStatus({
48
72
  code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
49
73
  });
50
- if (call.route === null || !ignored.has(call.route)) {
74
+ const isIgnored = call.route !== null && ignored.has(call.route);
75
+ if (!isIgnored) {
51
76
  instruments ??= createInstruments(options.meter ?? metrics.getMeter("webhooks"));
52
77
  const attributes = {
53
78
  route: call.route ?? UNMATCHED_ROUTE,
@@ -56,22 +81,35 @@ function otelWebhooks(options = {}) {
56
81
  instruments.total.add(1, attributes);
57
82
  instruments.duration.record(event.durationMs, attributes);
58
83
  }
84
+ if (span && options.logger && call.route !== null && !isIgnored) {
85
+ options.logger(span)[call.level]("webhook.completed", {
86
+ event: "webhook.completed",
87
+ route: call.route,
88
+ method: call.method,
89
+ status_code: call.status,
90
+ duration_ms: call.durationMs,
91
+ status: call.outcome,
92
+ ...call.error && { error_type: call.error.name }
93
+ });
94
+ }
59
95
  return options.onWebhookCall?.(call, event);
60
96
  }
61
97
  };
62
98
  }
63
99
  function createInstruments(meter) {
100
+ const [total, duration] = webhookMetrics;
64
101
  return {
65
- total: meter.createCounter("webhook_request_total", {
66
- description: "HTTP webhook requests by route template and status"
102
+ total: meter.createCounter(total.name, {
103
+ description: total.description
67
104
  }),
68
- duration: meter.createHistogram("webhook_request_duration_ms", {
69
- unit: "ms",
70
- description: "HTTP webhook handler duration by route template and status"
105
+ duration: meter.createHistogram(duration.name, {
106
+ unit: duration.unit,
107
+ description: duration.description
71
108
  })
72
109
  };
73
110
  }
74
111
  export {
75
112
  UNMATCHED_ROUTE,
76
- otelWebhooks
113
+ otelWebhooks,
114
+ webhookMetrics
77
115
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-webhooks",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,7 +88,7 @@
88
88
  "@opentelemetry/sdk-trace-base": "^2.12.0",
89
89
  "@types/bun": "^1.4.2",
90
90
  "@typescript/native-preview": "^7.0.0-dev.20260707.2",
91
- "hanzio": "^3.1.0",
91
+ "hanzio": "^3.3.0",
92
92
  "hono": "^4.13.10",
93
93
  "husky": "^9.1.7",
94
94
  "typescript": "^7.0.2",
@@ -2,7 +2,13 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
- import { CliError, type CliWriter, defineCli, defineCommand } from 'hanzio/cli'
5
+ import {
6
+ CliError,
7
+ CliUsageError,
8
+ type CommandContext,
9
+ defineCli,
10
+ defineCommand
11
+ } from 'hanzio/cli'
6
12
  import { z } from 'zod'
7
13
  import {
8
14
  createWebhooks,
@@ -78,193 +84,201 @@ const BUN_ONLY_ERRORS = new Set([
78
84
  /**
79
85
  * Under Node, a Bun project's TypeScript (extensionless imports, for one)
80
86
  * fails to load. Rerun the same command under Bun when it is installed and
81
- * return its exit code.
87
+ * exit with its code.
82
88
  */
83
- function rerunUnderBun(error: unknown, stderr: CliWriter): number {
89
+ function rerunUnderBun(error: unknown): never {
84
90
  const code = (error as { code?: string }).code
85
91
  if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code)) throw error
86
- stderr.write(`Node can't load the module (${code}), trying Bun\n`)
92
+ process.stderr.write(`Node can't load the module (${code}), trying Bun\n`)
87
93
  const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
88
94
  if (run.error) {
89
95
  throw new CliError(
90
96
  'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
91
97
  )
92
98
  }
93
- return run.status ?? 1
99
+ process.exit(run.status ?? 1)
94
100
  }
95
101
 
96
- // One command with <module> <command> positionals: hanzio/cli reads command
97
- // names first, so it can't put the module before them.
98
- export const cli = defineCli({
99
- name: 'hono-webhooks',
100
- commands: defineCommand({
101
- description: `<module> exports a createWebhooks() app or an array of webhooks, as default
102
- or named export.
102
+ type Ctx = CommandContext<WebhooksApp>
103
103
 
104
- Commands:
105
- list List the webhooks
106
- schema <path> Print the JSON Schemas of the webhooks at <path>
107
- test <method> <path> Send a request through the webhooks app
104
+ const print = (ctx: Ctx, line: string) => ctx.stdout.write(`${line}\n`)
108
105
 
109
- Examples:
110
- hono-webhooks ./src/webhooks.ts list
111
- hono-webhooks ./src/webhooks.ts schema /orders/:id
112
- hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
113
- hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
114
- -r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET`,
115
- input: z.object({
116
- module: z
117
- .string()
118
- .optional()
119
- .meta({ positional: true, description: 'Module with the webhooks' }),
120
- command: z
121
- .enum(['list', 'schema', 'test'])
122
- .optional()
123
- .meta({ positional: true, description: 'list, schema or test' }),
124
- args: z.array(z.string()).meta({
125
- positional: true,
126
- description: 'schema: <path>, test: <method> <path>'
127
- }),
128
- preload: z.array(z.string()).meta({
129
- alias: 'r',
130
- description: 'Import a module first (e.g. to load secrets), repeatable'
131
- }),
132
- body: z.string().optional().meta({
133
- alias: 'b',
134
- description:
135
- 'test: request body (JSON sets content-type: application/json)'
136
- }),
137
- query: z.string().optional().meta({
138
- alias: 'q',
139
- description: 'test: query string, e.g. "a=1&b=2"'
140
- }),
141
- header: z.array(z.string()).meta({
142
- alias: 'H',
143
- description: 'test: request header "name: value", repeatable'
144
- }),
145
- sign: z
146
- .string()
147
- .optional()
148
- .meta({
149
- alias: 's',
150
- description: `test: sign the body with ${Object.keys(schemes).join(', ')}`
151
- }),
152
- secretEnv: z.string().optional().meta({
153
- alias: 'e',
154
- description: 'test: environment variable holding the signing secret'
155
- })
156
- }),
157
- async run(input, ctx) {
158
- const print = (line: string) => ctx.stdout.write(`${line}\n`)
159
- if (!input.module || !input.command) {
160
- ctx.exitCode = await cli.run(['--help'], ctx)
161
- return
162
- }
163
- let app: WebhooksApp
164
- try {
165
- for (const preload of input.preload) await importModule(preload)
166
- app = await loadApp(input.module)
167
- } catch (error) {
168
- ctx.exitCode = rerunUnderBun(error, ctx.stderr)
169
- return
170
- }
106
+ const list = defineCommand({
107
+ description: 'List the webhooks',
108
+ examples: ['hono-webhooks ./src/webhooks.ts list'],
109
+ run(_, ctx: Ctx) {
110
+ for (const { config } of ctx.context.webhooks) {
111
+ const validates = [
112
+ ...(['params', 'query', 'headers', 'body'] as const).filter(
113
+ (key) => config[key]
114
+ ),
115
+ ...(config.verify ? ['signature'] : [])
116
+ ].join(', ')
117
+ print(
118
+ ctx,
119
+ `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
120
+ )
121
+ if (validates) print(ctx, ` validates ${validates}`)
122
+ }
123
+ }
124
+ })
171
125
 
172
- if (input.command === 'list') {
173
- for (const { config } of app.webhooks) {
174
- const validates = [
175
- ...(['params', 'query', 'headers', 'body'] as const).filter(
176
- (key) => config[key]
177
- ),
178
- ...(config.verify ? ['signature'] : [])
179
- ].join(', ')
180
- print(
181
- `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
182
- )
183
- if (validates) print(` validates ${validates}`)
184
- }
185
- return
126
+ const schema = defineCommand({
127
+ description: 'Print the JSON Schemas of the webhooks at <path>',
128
+ examples: ['hono-webhooks ./src/webhooks.ts schema /orders/:id'],
129
+ input: z.object({
130
+ path: z.string().meta({ positional: true, description: 'Webhook path' })
131
+ }),
132
+ run({ path }, ctx: Ctx) {
133
+ const app = ctx.context
134
+ const descriptions = describeWebhooks(app)
135
+ const indexes = app.webhooks.flatMap((w, i) =>
136
+ w.config.path === path ? [i] : []
137
+ )
138
+ if (!indexes.length) throw new CliError(`No webhook at ${path}`)
139
+ for (const i of indexes) {
140
+ const { config } = app.webhooks[i]!
141
+ print(ctx, `${config.method} ${config.path}`)
142
+ for (const part of [
143
+ 'params',
144
+ 'query',
145
+ 'headers',
146
+ 'body',
147
+ 'response'
148
+ ] as const) {
149
+ if (!config[part]) continue
150
+ const schema = descriptions[i]!.jsonSchema[part]
151
+ print(
152
+ ctx,
153
+ ` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
154
+ )
186
155
  }
156
+ }
157
+ }
158
+ })
187
159
 
188
- if (input.command === 'schema') {
189
- const [path] = input.args
190
- const descriptions = describeWebhooks(app)
191
- const indexes = app.webhooks.flatMap((w, i) =>
192
- w.config.path === path ? [i] : []
160
+ const testCommand = defineCommand({
161
+ description: 'Send a request through the webhooks app',
162
+ examples: [
163
+ `hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'`,
164
+ `hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
165
+ -r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET`
166
+ ],
167
+ input: z.object({
168
+ method: z
169
+ .string()
170
+ .meta({ positional: true, description: 'HTTP method, e.g. POST' }),
171
+ path: z.string().meta({ positional: true, description: 'Request path' }),
172
+ body: z.string().optional().meta({
173
+ alias: 'b',
174
+ description: 'Request body (JSON sets content-type: application/json)'
175
+ }),
176
+ query: z.string().optional().meta({
177
+ alias: 'q',
178
+ description: 'Query string, e.g. "a=1&b=2"'
179
+ }),
180
+ header: z.array(z.string()).meta({
181
+ alias: 'H',
182
+ description: 'Request header "name: value", repeatable'
183
+ }),
184
+ sign: z
185
+ .string()
186
+ .optional()
187
+ .meta({
188
+ alias: 's',
189
+ description: `Sign the body: ${Object.keys(schemes).join(', ')}`
190
+ }),
191
+ secretEnv: z.string().optional().meta({
192
+ alias: 'e',
193
+ description: 'Environment variable holding the signing secret'
194
+ })
195
+ }),
196
+ async run(input, ctx: Ctx) {
197
+ const { method, path } = input
198
+ const headers = new Headers()
199
+ for (const header of input.header) {
200
+ const at = header.indexOf(':')
201
+ if (at < 1) {
202
+ throw new CliError(
203
+ `Header must look like "name: value", got "${header}"`
193
204
  )
194
- if (!indexes.length) throw new CliError(`No webhook at ${path}`)
195
- for (const i of indexes) {
196
- const { config } = app.webhooks[i]!
197
- print(`${config.method} ${config.path}`)
198
- for (const part of [
199
- 'params',
200
- 'query',
201
- 'headers',
202
- 'body',
203
- 'response'
204
- ] as const) {
205
- if (!config[part]) continue
206
- const schema = descriptions[i]!.jsonSchema[part]
207
- print(
208
- ` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
209
- )
210
- }
211
- }
212
- return
213
205
  }
214
-
215
- const [method, path] = input.args
216
- if (!method || !path) throw new CliError('Usage: test <method> <path>')
217
- const headers = new Headers()
218
- for (const header of input.header) {
219
- const at = header.indexOf(':')
220
- if (at < 1) {
221
- throw new CliError(
222
- `Header must look like "name: value", got "${header}"`
223
- )
224
- }
225
- headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
206
+ headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
207
+ }
208
+ if (input.body && !headers.has('content-type')) {
209
+ try {
210
+ JSON.parse(input.body)
211
+ headers.set('content-type', 'application/json')
212
+ } catch {
213
+ headers.set('content-type', 'text/plain')
226
214
  }
227
- if (input.body && !headers.has('content-type')) {
228
- try {
229
- JSON.parse(input.body)
230
- headers.set('content-type', 'application/json')
231
- } catch {
232
- headers.set('content-type', 'text/plain')
233
- }
215
+ }
216
+ if (input.sign) {
217
+ const scheme = schemes[input.sign]
218
+ if (!scheme) {
219
+ throw new CliError(
220
+ `Unknown scheme "${input.sign}", use one of: ${Object.keys(schemes).join(', ')}`
221
+ )
234
222
  }
235
- if (input.sign) {
236
- const scheme = schemes[input.sign]
237
- if (!scheme) {
238
- throw new CliError(
239
- `Unknown scheme "${input.sign}", use one of: ${Object.keys(schemes).join(', ')}`
240
- )
241
- }
242
- const name = input.secretEnv
243
- if (!name) throw new CliError('--sign needs --secret-env <name>')
244
- const secret = ctx.env[name]
245
- if (secret === undefined) throw new CliError(`${name} is not set`)
246
- for (const [key, value] of Object.entries(
247
- await scheme.sign(input.body ?? '', secret)
248
- )) {
249
- headers.set(key, value)
250
- }
223
+ const name = input.secretEnv
224
+ if (!name) throw new CliError('--sign needs --secret-env <name>')
225
+ const secret = ctx.env[name]
226
+ if (secret === undefined) throw new CliError(`${name} is not set`)
227
+ for (const [key, value] of Object.entries(
228
+ await scheme.sign(input.body ?? '', secret)
229
+ )) {
230
+ headers.set(key, value)
251
231
  }
232
+ }
252
233
 
253
- const url = `${path.startsWith('/') ? path : `/${path}`}${input.query ? `?${input.query}` : ''}`
254
- const start = performance.now()
255
- const res = await app.request(url, {
256
- method: method.toUpperCase(),
257
- headers,
258
- body: input.body
259
- })
260
- const text = await res.text()
261
- let body = text
262
- try {
263
- body = JSON.stringify(JSON.parse(text), null, 2)
264
- } catch {}
265
- print(`${res.status} (${(performance.now() - start).toFixed(1)}ms)`)
266
- if (body) print(body)
267
- if (!res.ok) ctx.exitCode = 1
234
+ const url = `${path.startsWith('/') ? path : `/${path}`}${input.query ? `?${input.query}` : ''}`
235
+ const start = performance.now()
236
+ const res = await ctx.context.request(url, {
237
+ method: method.toUpperCase(),
238
+ headers,
239
+ body: input.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
+ print(ctx, `${res.status} (${(performance.now() - start).toFixed(1)}ms)`)
247
+ if (body) print(ctx, body)
248
+ if (!res.ok) ctx.exitCode = 1
249
+ }
250
+ })
251
+
252
+ export const cli = defineCli({
253
+ name: 'hono-webhooks',
254
+ description: `<module> exports a createWebhooks() app or an array of webhooks, as default
255
+ or named export.`,
256
+ globals: z.object({
257
+ module: z
258
+ .string()
259
+ .meta({ positional: true, description: 'Module with the webhooks' }),
260
+ preload: z.array(z.string()).meta({
261
+ alias: 'r',
262
+ description: 'Import a module first (e.g. to load secrets), repeatable'
263
+ })
264
+ }),
265
+ async context({ module, preload }) {
266
+ try {
267
+ for (const specifier of preload) await importModule(specifier)
268
+ return await loadApp(module)
269
+ } catch (error) {
270
+ return rerunUnderBun(error)
268
271
  }
269
- })
272
+ },
273
+ commands: { list, schema, test: testCommand },
274
+ onError(error, io) {
275
+ // No module or command: print help and succeed, as `--help` does.
276
+ if (error instanceof CliUsageError && error.kind === 'missing-command') {
277
+ io.stdout.write(error.help)
278
+ return 0
279
+ }
280
+ // Bad arguments exit 1 like every other failure.
281
+ const code = io.print()
282
+ return code === 2 ? 1 : code
283
+ }
270
284
  })
package/src/cli.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli } from './cli-commands'
3
3
 
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
4
+ process.exitCode = await cli.run()