hono-webhooks 0.5.0 → 0.6.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 +262 -37
- package/dist/cli.js +5632 -5338
- package/dist/index.d.ts +69 -11
- package/dist/index.js +60 -19
- package/dist/otel.d.ts +9 -5
- package/dist/otel.js +8 -5
- package/dist/signatures.d.ts +64 -0
- package/dist/signatures.js +171 -24
- package/package.json +2 -2
- package/src/cli-commands.ts +198 -169
- package/src/cli.ts +1 -3
- package/src/index.ts +141 -31
- package/src/otel.ts +17 -9
- package/src/signatures.ts +277 -33
package/src/cli-commands.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
@@ -12,19 +18,30 @@ import {
|
|
|
12
18
|
} from './index'
|
|
13
19
|
import {
|
|
14
20
|
github,
|
|
21
|
+
hubspot,
|
|
22
|
+
infisical,
|
|
23
|
+
meta,
|
|
15
24
|
type SignatureScheme,
|
|
16
25
|
shopify,
|
|
17
26
|
stripe,
|
|
18
|
-
svix
|
|
27
|
+
svix,
|
|
28
|
+
twilio
|
|
19
29
|
} from './signatures'
|
|
20
30
|
|
|
21
31
|
const schemes: Record<string, SignatureScheme<any, any>> = {
|
|
22
32
|
github,
|
|
33
|
+
hubspot,
|
|
34
|
+
infisical,
|
|
35
|
+
meta,
|
|
23
36
|
shopify,
|
|
24
37
|
stripe,
|
|
25
|
-
svix
|
|
38
|
+
svix,
|
|
39
|
+
twilio
|
|
26
40
|
}
|
|
27
41
|
|
|
42
|
+
/** `request()` with a bare path runs at this origin, so URL-signing schemes sign it. */
|
|
43
|
+
const ORIGIN = 'http://localhost'
|
|
44
|
+
|
|
28
45
|
/** A file path relative to the cwd, or a package name. */
|
|
29
46
|
const importModule = (specifier: string) =>
|
|
30
47
|
import(
|
|
@@ -78,193 +95,205 @@ const BUN_ONLY_ERRORS = new Set([
|
|
|
78
95
|
/**
|
|
79
96
|
* Under Node, a Bun project's TypeScript (extensionless imports, for one)
|
|
80
97
|
* fails to load. Rerun the same command under Bun when it is installed and
|
|
81
|
-
*
|
|
98
|
+
* exit with its code.
|
|
82
99
|
*/
|
|
83
|
-
function rerunUnderBun(error: unknown
|
|
100
|
+
function rerunUnderBun(error: unknown): never {
|
|
84
101
|
const code = (error as { code?: string }).code
|
|
85
102
|
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`)
|
|
103
|
+
process.stderr.write(`Node can't load the module (${code}), trying Bun\n`)
|
|
87
104
|
const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
|
|
88
105
|
if (run.error) {
|
|
89
106
|
throw new CliError(
|
|
90
107
|
'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
|
|
91
108
|
)
|
|
92
109
|
}
|
|
93
|
-
|
|
110
|
+
process.exit(run.status ?? 1)
|
|
94
111
|
}
|
|
95
112
|
|
|
96
|
-
|
|
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.
|
|
113
|
+
type Ctx = CommandContext<WebhooksApp>
|
|
103
114
|
|
|
104
|
-
|
|
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
|
|
115
|
+
const print = (ctx: Ctx, line: string) => ctx.stdout.write(`${line}\n`)
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
}
|
|
117
|
+
const list = defineCommand({
|
|
118
|
+
description: 'List the webhooks',
|
|
119
|
+
examples: ['hono-webhooks ./src/webhooks.ts list'],
|
|
120
|
+
run(_, ctx: Ctx) {
|
|
121
|
+
for (const { config } of ctx.context.webhooks) {
|
|
122
|
+
const validates = [
|
|
123
|
+
...(['params', 'query', 'headers', 'body'] as const).filter(
|
|
124
|
+
(key) => config[key]
|
|
125
|
+
),
|
|
126
|
+
...(config.verify ? ['signature'] : [])
|
|
127
|
+
].join(', ')
|
|
128
|
+
print(
|
|
129
|
+
ctx,
|
|
130
|
+
`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
|
|
131
|
+
)
|
|
132
|
+
if (validates) print(ctx, ` validates ${validates}`)
|
|
133
|
+
if (config.verify === false) print(ctx, ' public (verify: false)')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
171
137
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
138
|
+
const schema = defineCommand({
|
|
139
|
+
description: 'Print the JSON Schemas of the webhooks at <path>',
|
|
140
|
+
examples: ['hono-webhooks ./src/webhooks.ts schema /orders/:id'],
|
|
141
|
+
input: z.object({
|
|
142
|
+
path: z.string().meta({ positional: true, description: 'Webhook path' })
|
|
143
|
+
}),
|
|
144
|
+
run({ path }, ctx: Ctx) {
|
|
145
|
+
const app = ctx.context
|
|
146
|
+
const descriptions = describeWebhooks(app)
|
|
147
|
+
const indexes = app.webhooks.flatMap((w, i) =>
|
|
148
|
+
w.config.path === path ? [i] : []
|
|
149
|
+
)
|
|
150
|
+
if (!indexes.length) throw new CliError(`No webhook at ${path}`)
|
|
151
|
+
for (const i of indexes) {
|
|
152
|
+
const { config } = app.webhooks[i]!
|
|
153
|
+
print(ctx, `${config.method} ${config.path}`)
|
|
154
|
+
for (const part of [
|
|
155
|
+
'params',
|
|
156
|
+
'query',
|
|
157
|
+
'headers',
|
|
158
|
+
'body',
|
|
159
|
+
'response'
|
|
160
|
+
] as const) {
|
|
161
|
+
if (!config[part]) continue
|
|
162
|
+
const schema = descriptions[i]!.jsonSchema[part]
|
|
163
|
+
print(
|
|
164
|
+
ctx,
|
|
165
|
+
` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
|
|
166
|
+
)
|
|
186
167
|
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
})
|
|
187
171
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
172
|
+
const testCommand = defineCommand({
|
|
173
|
+
description: 'Send a request through the webhooks app',
|
|
174
|
+
examples: [
|
|
175
|
+
`hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'`,
|
|
176
|
+
`hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
|
|
177
|
+
-r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET`
|
|
178
|
+
],
|
|
179
|
+
input: z.object({
|
|
180
|
+
method: z
|
|
181
|
+
.string()
|
|
182
|
+
.meta({ positional: true, description: 'HTTP method, e.g. POST' }),
|
|
183
|
+
path: z.string().meta({ positional: true, description: 'Request path' }),
|
|
184
|
+
body: z.string().optional().meta({
|
|
185
|
+
alias: 'b',
|
|
186
|
+
description: 'Request body (JSON sets content-type: application/json)'
|
|
187
|
+
}),
|
|
188
|
+
query: z.string().optional().meta({
|
|
189
|
+
alias: 'q',
|
|
190
|
+
description: 'Query string, e.g. "a=1&b=2"'
|
|
191
|
+
}),
|
|
192
|
+
header: z.array(z.string()).meta({
|
|
193
|
+
alias: 'H',
|
|
194
|
+
description: 'Request header "name: value", repeatable'
|
|
195
|
+
}),
|
|
196
|
+
sign: z
|
|
197
|
+
.string()
|
|
198
|
+
.optional()
|
|
199
|
+
.meta({
|
|
200
|
+
alias: 's',
|
|
201
|
+
description: `Sign the body: ${Object.keys(schemes).join(', ')}`
|
|
202
|
+
}),
|
|
203
|
+
secretEnv: z.string().optional().meta({
|
|
204
|
+
alias: 'e',
|
|
205
|
+
description: 'Environment variable holding the signing secret'
|
|
206
|
+
})
|
|
207
|
+
}),
|
|
208
|
+
async run(input, ctx: Ctx) {
|
|
209
|
+
const { method, path } = input
|
|
210
|
+
const headers = new Headers()
|
|
211
|
+
for (const header of input.header) {
|
|
212
|
+
const at = header.indexOf(':')
|
|
213
|
+
if (at < 1) {
|
|
214
|
+
throw new CliError(
|
|
215
|
+
`Header must look like "name: value", got "${header}"`
|
|
193
216
|
)
|
|
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
217
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
`Header must look like "name: value", got "${header}"`
|
|
223
|
-
)
|
|
224
|
-
}
|
|
225
|
-
headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
|
|
218
|
+
headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
|
|
219
|
+
}
|
|
220
|
+
if (input.body && !headers.has('content-type')) {
|
|
221
|
+
try {
|
|
222
|
+
JSON.parse(input.body)
|
|
223
|
+
headers.set('content-type', 'application/json')
|
|
224
|
+
} catch {
|
|
225
|
+
headers.set('content-type', 'text/plain')
|
|
226
226
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
227
|
+
}
|
|
228
|
+
const url = `${path.startsWith('/') ? path : `/${path}`}${input.query ? `?${input.query}` : ''}`
|
|
229
|
+
if (input.sign) {
|
|
230
|
+
const scheme = schemes[input.sign]
|
|
231
|
+
if (!scheme) {
|
|
232
|
+
throw new CliError(
|
|
233
|
+
`Unknown scheme "${input.sign}", use one of: ${Object.keys(schemes).join(', ')}`
|
|
234
|
+
)
|
|
234
235
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
for (const [key, value] of Object.entries(
|
|
247
|
-
await scheme.sign(input.body ?? '', secret)
|
|
248
|
-
)) {
|
|
249
|
-
headers.set(key, value)
|
|
250
|
-
}
|
|
236
|
+
const name = input.secretEnv
|
|
237
|
+
if (!name) throw new CliError('--sign needs --secret-env <name>')
|
|
238
|
+
const secret = ctx.env[name]
|
|
239
|
+
if (secret === undefined) throw new CliError(`${name} is not set`)
|
|
240
|
+
for (const [key, value] of Object.entries(
|
|
241
|
+
await scheme.sign(input.body ?? '', secret, {
|
|
242
|
+
url: ORIGIN + url,
|
|
243
|
+
method: method.toUpperCase()
|
|
244
|
+
})
|
|
245
|
+
)) {
|
|
246
|
+
headers.set(key, value)
|
|
251
247
|
}
|
|
248
|
+
}
|
|
252
249
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
250
|
+
const start = performance.now()
|
|
251
|
+
const res = await ctx.context.request(url, {
|
|
252
|
+
method: method.toUpperCase(),
|
|
253
|
+
headers,
|
|
254
|
+
body: input.body
|
|
255
|
+
})
|
|
256
|
+
const text = await res.text()
|
|
257
|
+
let body = text
|
|
258
|
+
try {
|
|
259
|
+
body = JSON.stringify(JSON.parse(text), null, 2)
|
|
260
|
+
} catch {}
|
|
261
|
+
print(ctx, `${res.status} (${(performance.now() - start).toFixed(1)}ms)`)
|
|
262
|
+
if (body) print(ctx, body)
|
|
263
|
+
if (!res.ok) ctx.exitCode = 1
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
export const cli = defineCli({
|
|
268
|
+
name: 'hono-webhooks',
|
|
269
|
+
description: `<module> exports a createWebhooks() app or an array of webhooks, as default
|
|
270
|
+
or named export.`,
|
|
271
|
+
globals: z.object({
|
|
272
|
+
module: z
|
|
273
|
+
.string()
|
|
274
|
+
.meta({ positional: true, description: 'Module with the webhooks' }),
|
|
275
|
+
preload: z.array(z.string()).meta({
|
|
276
|
+
alias: 'r',
|
|
277
|
+
description: 'Import a module first (e.g. to load secrets), repeatable'
|
|
278
|
+
})
|
|
279
|
+
}),
|
|
280
|
+
async context({ module, preload }) {
|
|
281
|
+
try {
|
|
282
|
+
for (const specifier of preload) await importModule(specifier)
|
|
283
|
+
return await loadApp(module)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return rerunUnderBun(error)
|
|
268
286
|
}
|
|
269
|
-
}
|
|
287
|
+
},
|
|
288
|
+
commands: { list, schema, test: testCommand },
|
|
289
|
+
onError(error, io) {
|
|
290
|
+
// No module or command: print help and succeed, as `--help` does.
|
|
291
|
+
if (error instanceof CliUsageError && error.kind === 'missing-command') {
|
|
292
|
+
io.stdout.write(error.help)
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
// Bad arguments exit 1 like every other failure.
|
|
296
|
+
const code = io.print()
|
|
297
|
+
return code === 2 ? 1 : code
|
|
298
|
+
}
|
|
270
299
|
})
|
package/src/cli.ts
CHANGED