hono-webhooks 0.3.0 → 0.4.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 ADDED
@@ -0,0 +1,31 @@
1
+ import { type Meter, type Tracer } from '@opentelemetry/api';
2
+ import type { Env } from 'hono';
3
+ import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
4
+ /** The `route` label for 404s, which match no webhook. */
5
+ export declare const UNMATCHED_ROUTE = "__unmatched__";
6
+ export interface OtelWebhooksOptions<E extends Env = any> {
7
+ /** @default trace.getTracer('webhooks') */
8
+ tracer?: Tracer;
9
+ /** @default metrics.getMeter('webhooks'), resolved on the first request */
10
+ meter?: Meter;
11
+ /** Routes that get a span but no metrics, e.g. `['/health']`. */
12
+ ignoreRoutes?: readonly string[];
13
+ /**
14
+ * Called after every request, ignored routes included, with the span
15
+ * still active: log it, store it. A returned promise is handled like
16
+ * `onEvent`'s.
17
+ */
18
+ onWebhookCall?: (call: WebhookEventSummary, event: WebhookEvent<E>) => void | Promise<void>;
19
+ }
20
+ /**
21
+ * `around` and `onEvent` that put each request in a server span and record
22
+ * `webhook_request_total` and `webhook_request_duration_ms` by route and
23
+ * outcome.
24
+ *
25
+ * With `createWebhookFactory<AppEnv>()`, call `otelWebhooks<AppEnv>(...)`
26
+ * so `onWebhookCall` gets a typed context.
27
+ *
28
+ * @example
29
+ * createWebhooks(webhooks, { ...otelWebhooks({ ignoreRoutes: ['/health'] }), logErrors: false })
30
+ */
31
+ export declare function otelWebhooks<E extends Env = any>(options?: OtelWebhooksOptions<E>): Required<Pick<WebhooksOptions<E>, 'around' | 'onEvent'>>;
package/dist/otel.js ADDED
@@ -0,0 +1,77 @@
1
+ // src/otel.ts
2
+ import {
3
+ metrics,
4
+ SpanKind,
5
+ SpanStatusCode,
6
+ trace
7
+ } from "@opentelemetry/api";
8
+ import {
9
+ summarizeEvent
10
+ } from "hono-webhooks";
11
+ var UNMATCHED_ROUTE = "__unmatched__";
12
+ function otelWebhooks(options = {}) {
13
+ const tracer = options.tracer ?? trace.getTracer("webhooks");
14
+ const ignored = new Set(options.ignoreRoutes);
15
+ const spans = new WeakMap;
16
+ let instruments;
17
+ return {
18
+ around: ({ c, route, method }, next) => {
19
+ const label = route ?? UNMATCHED_ROUTE;
20
+ return tracer.startActiveSpan(`webhook: ${method} ${label}`, {
21
+ kind: SpanKind.SERVER,
22
+ attributes: { "http.method": method, "http.route": label }
23
+ }, async (span) => {
24
+ spans.set(c, span);
25
+ try {
26
+ return await next();
27
+ } finally {
28
+ span.end();
29
+ }
30
+ });
31
+ },
32
+ onEvent: (event) => {
33
+ const call = summarizeEvent(event);
34
+ const success = call.status < 400;
35
+ const span = spans.get(event.c);
36
+ span?.setAttributes({
37
+ "http.status_code": call.status,
38
+ "webhook.duration_ms": call.durationMs,
39
+ "webhook.success": success
40
+ });
41
+ if (call.validation) {
42
+ span?.setAttribute("webhook.validation_error", call.validation.message);
43
+ }
44
+ if (event.error instanceof Error && call.status >= 500) {
45
+ span?.recordException(event.error);
46
+ }
47
+ span?.setStatus({
48
+ code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
49
+ });
50
+ if (call.route === null || !ignored.has(call.route)) {
51
+ instruments ??= createInstruments(options.meter ?? metrics.getMeter("webhooks"));
52
+ const attributes = {
53
+ route: call.route ?? UNMATCHED_ROUTE,
54
+ status: success ? "success" : "failed"
55
+ };
56
+ instruments.total.add(1, attributes);
57
+ instruments.duration.record(event.durationMs, attributes);
58
+ }
59
+ return options.onWebhookCall?.(call, event);
60
+ }
61
+ };
62
+ }
63
+ function createInstruments(meter) {
64
+ return {
65
+ total: meter.createCounter("webhook_request_total", {
66
+ description: "HTTP webhook requests by route template and status"
67
+ }),
68
+ duration: meter.createHistogram("webhook_request_duration_ms", {
69
+ unit: "ms",
70
+ description: "HTTP webhook handler duration by route template and status"
71
+ })
72
+ };
73
+ }
74
+ export {
75
+ UNMATCHED_ROUTE,
76
+ otelWebhooks
77
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-webhooks",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -25,7 +25,8 @@
25
25
  "valibot",
26
26
  "arktype",
27
27
  "typescript",
28
- "type-safe"
28
+ "type-safe",
29
+ "opentelemetry"
29
30
  ],
30
31
  "files": [
31
32
  "src/**/*.ts",
@@ -48,11 +49,18 @@
48
49
  "bun": "./src/signatures.ts",
49
50
  "import": "./dist/signatures.js",
50
51
  "default": "./dist/signatures.js"
52
+ },
53
+ "./otel": {
54
+ "types": "./dist/otel.d.ts",
55
+ "bun": "./src/otel.ts",
56
+ "import": "./dist/otel.js",
57
+ "default": "./dist/otel.js"
51
58
  }
52
59
  },
53
60
  "scripts": {
54
61
  "build": "rm -rf dist && bun run build:js && bun run build:types",
55
- "build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/cli.ts --outdir ./dist --target node --packages external",
62
+ "build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/otel.ts --outdir ./dist --target node --packages external && bun run build:cli",
63
+ "build:cli": "bun build ./src/cli.ts --outdir ./dist --target node --external hono --external @standard-schema/spec",
56
64
  "build:types": "tsgo -p tsconfig.build.json",
57
65
  "prepublishOnly": "bun run build",
58
66
  "test": "bun test",
@@ -65,12 +73,22 @@
65
73
  "@standard-schema/spec": "^1.1.0"
66
74
  },
67
75
  "peerDependencies": {
68
- "hono": "^4.8.0"
76
+ "hono": "^4.8.0",
77
+ "@opentelemetry/api": "^1.9.0"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "@opentelemetry/api": {
81
+ "optional": true
82
+ }
69
83
  },
70
84
  "devDependencies": {
71
85
  "@biomejs/biome": "^2.5.14",
86
+ "@opentelemetry/api": "^1.9.1",
87
+ "@opentelemetry/sdk-metrics": "^2.12.0",
88
+ "@opentelemetry/sdk-trace-base": "^2.12.0",
72
89
  "@types/bun": "^1.4.2",
73
90
  "@typescript/native-preview": "^7.0.0-dev.20260707.2",
91
+ "hanzio": "^3.1.0",
74
92
  "hono": "^4.13.10",
75
93
  "husky": "^9.1.7",
76
94
  "typescript": "^7.0.2",
@@ -0,0 +1,270 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { CliError, type CliWriter, defineCli, defineCommand } from 'hanzio/cli'
6
+ import { z } from 'zod'
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'
20
+
21
+ const schemes: Record<string, SignatureScheme<any, any>> = {
22
+ github,
23
+ shopify,
24
+ stripe,
25
+ svix
26
+ }
27
+
28
+ /** A file path relative to the cwd, or a package name. */
29
+ const importModule = (specifier: string) =>
30
+ import(
31
+ existsSync(resolve(specifier))
32
+ ? pathToFileURL(resolve(specifier)).href
33
+ : specifier
34
+ )
35
+
36
+ function isWebhooksApp(value: unknown): value is WebhooksApp {
37
+ return (
38
+ typeof value === 'object' &&
39
+ value !== null &&
40
+ Array.isArray((value as WebhooksApp).webhooks) &&
41
+ typeof (value as WebhooksApp).request === 'function'
42
+ )
43
+ }
44
+
45
+ function isWebhookArray(value: unknown): value is Webhook[] {
46
+ return (
47
+ Array.isArray(value) &&
48
+ value.length > 0 &&
49
+ value.every(
50
+ (item) =>
51
+ typeof item?.config?.path === 'string' &&
52
+ typeof item?.handler === 'function'
53
+ )
54
+ )
55
+ }
56
+
57
+ async function loadApp(path: string): Promise<WebhooksApp> {
58
+ const mod = await importModule(path)
59
+ const candidates = [mod.default, ...Object.values(mod)]
60
+ const app = candidates.find(isWebhooksApp)
61
+ if (app) return app
62
+ const webhooks = candidates.find(isWebhookArray)
63
+ if (webhooks) return createWebhooks(webhooks)
64
+ throw new CliError(
65
+ `${path} exports neither a createWebhooks() app nor an array of webhooks`
66
+ )
67
+ }
68
+
69
+ /** Errors Node gives for TypeScript it can't load but Bun can. */
70
+ const BUN_ONLY_ERRORS = new Set([
71
+ 'ERR_MODULE_NOT_FOUND',
72
+ 'ERR_UNKNOWN_FILE_EXTENSION',
73
+ 'ERR_UNSUPPORTED_DIR_IMPORT',
74
+ 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX',
75
+ 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
76
+ ])
77
+
78
+ /**
79
+ * Under Node, a Bun project's TypeScript (extensionless imports, for one)
80
+ * fails to load. Rerun the same command under Bun when it is installed and
81
+ * return its exit code.
82
+ */
83
+ function rerunUnderBun(error: unknown, stderr: CliWriter): number {
84
+ const code = (error as { code?: string }).code
85
+ 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`)
87
+ const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
88
+ if (run.error) {
89
+ throw new CliError(
90
+ 'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
91
+ )
92
+ }
93
+ return run.status ?? 1
94
+ }
95
+
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.
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
108
+
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
+ }
171
+
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
186
+ }
187
+
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] : []
193
+ )
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
+ }
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())
226
+ }
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
+ }
234
+ }
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
+ }
251
+ }
252
+
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
268
+ }
269
+ })
270
+ })
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