hono-webhooks 0.1.1 → 0.3.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,37 +1,68 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync } from 'node:fs'
2
4
  import { resolve } from 'node:path'
3
5
  import { pathToFileURL } from 'node:url'
4
6
  import { parseArgs } from 'node:util'
5
- import type { StandardJSONSchemaV1 } from '@standard-schema/spec'
6
- import type { WebhooksApp } from './index'
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
+ }
7
27
 
8
28
  const HELP = `Usage: hono-webhooks <module> <command> [options]
9
29
 
10
- <module> exports the result of createWebhooks(), as default or named export.
30
+ <module> exports a createWebhooks() app or an array of webhooks, as default
31
+ or named export.
11
32
 
12
33
  Commands:
13
34
  list List the webhooks
14
35
  schema <path> Print the JSON Schemas of the webhooks at <path>
15
36
  test <method> <path> Send a request through the webhooks app
16
37
 
38
+ Options:
39
+ -r, --preload <module> Import a module first (e.g. to load secrets), repeatable
40
+
17
41
  Options for test:
18
42
  -b, --body <text> Request body (JSON sets content-type: application/json)
19
43
  -q, --query <params> Query string, e.g. "a=1&b=2"
20
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
21
47
 
22
48
  Examples:
23
49
  hono-webhooks ./src/webhooks.ts list
24
50
  hono-webhooks ./src/webhooks.ts schema /orders/:id
25
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
26
54
  `
27
55
 
28
56
  const { values, positionals } = parseArgs({
29
57
  allowPositionals: true,
30
58
  options: {
31
59
  help: { type: 'boolean', short: 'h' },
60
+ preload: { type: 'string', short: 'r', multiple: true },
32
61
  body: { type: 'string', short: 'b' },
33
62
  query: { type: 'string', short: 'q' },
34
- header: { type: 'string', short: 'H', multiple: true }
63
+ header: { type: 'string', short: 'H', multiple: true },
64
+ sign: { type: 'string', short: 's' },
65
+ 'secret-env': { type: 'string', short: 'e' }
35
66
  }
36
67
  })
37
68
 
@@ -42,6 +73,14 @@ function fail(message: string): never {
42
73
  process.exit(1)
43
74
  }
44
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
+
45
84
  function isWebhooksApp(value: unknown): value is WebhooksApp {
46
85
  return (
47
86
  typeof value === 'object' &&
@@ -51,19 +90,66 @@ function isWebhooksApp(value: unknown): value is WebhooksApp {
51
90
  )
52
91
  }
53
92
 
54
- async function loadApp(path: string) {
55
- const mod = await import(pathToFileURL(resolve(path)).href)
56
- const app = [mod.default, ...Object.values(mod)].find(isWebhooksApp)
57
- return app ?? fail(`${path} does not export a createWebhooks() app`)
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
+ )
58
103
  }
59
104
 
60
- function jsonSchemaOf(schema: unknown) {
61
- const props = (schema as Partial<StandardJSONSchemaV1>)['~standard']
62
- try {
63
- return props?.jsonSchema?.input({ target: 'draft-2020-12' })
64
- } catch {
65
- return undefined
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
+ )
66
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)
67
153
  }
68
154
 
69
155
  async function main() {
@@ -71,13 +157,22 @@ async function main() {
71
157
  console.log(HELP)
72
158
  return
73
159
  }
74
- const app = await loadApp(modulePath)
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
+ }
75
167
 
76
168
  if (command === 'list') {
77
169
  for (const { config } of app.webhooks) {
78
- const validates = (['params', 'query', 'headers', 'body'] as const)
79
- .filter((key) => config[key])
80
- .join(', ')
170
+ const validates = [
171
+ ...(['params', 'query', 'headers', 'body'] as const).filter(
172
+ (key) => config[key]
173
+ ),
174
+ ...(config.verify ? ['signature'] : [])
175
+ ].join(', ')
81
176
  console.log(
82
177
  `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
83
178
  )
@@ -88,21 +183,25 @@ async function main() {
88
183
 
89
184
  if (command === 'schema') {
90
185
  const [path] = args
91
- const matches = app.webhooks.filter((w) => w.config.path === path)
92
- if (!matches.length) fail(`No webhook at ${path}`)
93
- for (const { config } of matches) {
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]!
94
193
  console.log(`${config.method} ${config.path}`)
95
- for (const key of [
194
+ for (const part of [
96
195
  'params',
97
196
  'query',
98
197
  'headers',
99
198
  'body',
100
199
  'response'
101
200
  ] as const) {
102
- if (!config[key]) continue
103
- const json = jsonSchemaOf(config[key])
201
+ if (!config[part]) continue
202
+ const schema = descriptions[i]!.jsonSchema[part]
104
203
  console.log(
105
- ` ${key}: ${json ? JSON.stringify(json, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
204
+ ` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
106
205
  )
107
206
  }
108
207
  }
@@ -126,6 +225,11 @@ async function main() {
126
225
  headers.set('content-type', 'text/plain')
127
226
  }
128
227
  }
228
+ for (const [name, value] of Object.entries(
229
+ await signatureHeaders(values.body ?? '')
230
+ )) {
231
+ headers.set(name, value)
232
+ }
129
233
 
130
234
  const url = `${path.startsWith('/') ? path : `/${path}`}${values.query ? `?${values.query}` : ''}`
131
235
  const start = performance.now()