hono-webhooks 0.1.0 → 0.2.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,60 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs'
2
3
  import { resolve } from 'node:path'
3
4
  import { pathToFileURL } from 'node:url'
4
5
  import { parseArgs } from 'node:util'
5
- import type { StandardJSONSchemaV1 } from '@standard-schema/spec'
6
- import type { WebhooksApp } from './index'
6
+ import {
7
+ createWebhooks,
8
+ describeWebhooks,
9
+ type Webhook,
10
+ type WebhooksApp
11
+ } from './index'
12
+ import { github, type SignatureScheme, shopify, stripe } from './signatures'
13
+
14
+ const schemes: Record<string, SignatureScheme<any, any>> = {
15
+ github,
16
+ shopify,
17
+ stripe
18
+ }
7
19
 
8
20
  const HELP = `Usage: hono-webhooks <module> <command> [options]
9
21
 
10
- <module> exports the result of createWebhooks(), as default or named export.
22
+ <module> exports a createWebhooks() app or an array of webhooks, as default
23
+ or named export.
11
24
 
12
25
  Commands:
13
26
  list List the webhooks
14
27
  schema <path> Print the JSON Schemas of the webhooks at <path>
15
28
  test <method> <path> Send a request through the webhooks app
16
29
 
30
+ Options:
31
+ -r, --preload <module> Import a module first (e.g. to load secrets), repeatable
32
+
17
33
  Options for test:
18
34
  -b, --body <text> Request body (JSON sets content-type: application/json)
19
35
  -q, --query <params> Query string, e.g. "a=1&b=2"
20
36
  -H, --header <k: v> Request header, repeatable
37
+ -s, --sign <scheme> Sign the body: ${Object.keys(schemes).join(', ')}
38
+ -e, --secret-env <name> Environment variable holding the signing secret
21
39
 
22
40
  Examples:
23
41
  hono-webhooks ./src/webhooks.ts list
24
42
  hono-webhooks ./src/webhooks.ts schema /orders/:id
25
43
  hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
44
+ hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
45
+ -r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET
26
46
  `
27
47
 
28
48
  const { values, positionals } = parseArgs({
29
49
  allowPositionals: true,
30
50
  options: {
31
51
  help: { type: 'boolean', short: 'h' },
52
+ preload: { type: 'string', short: 'r', multiple: true },
32
53
  body: { type: 'string', short: 'b' },
33
54
  query: { type: 'string', short: 'q' },
34
- header: { type: 'string', short: 'H', multiple: true }
55
+ header: { type: 'string', short: 'H', multiple: true },
56
+ sign: { type: 'string', short: 's' },
57
+ 'secret-env': { type: 'string', short: 'e' }
35
58
  }
36
59
  })
37
60
 
@@ -42,6 +65,14 @@ function fail(message: string): never {
42
65
  process.exit(1)
43
66
  }
44
67
 
68
+ /** A file path relative to the cwd, or a package name. */
69
+ const importModule = (specifier: string) =>
70
+ import(
71
+ existsSync(resolve(specifier))
72
+ ? pathToFileURL(resolve(specifier)).href
73
+ : specifier
74
+ )
75
+
45
76
  function isWebhooksApp(value: unknown): value is WebhooksApp {
46
77
  return (
47
78
  typeof value === 'object' &&
@@ -51,19 +82,40 @@ function isWebhooksApp(value: unknown): value is WebhooksApp {
51
82
  )
52
83
  }
53
84
 
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`)
85
+ function isWebhookArray(value: unknown): value is Webhook[] {
86
+ return (
87
+ Array.isArray(value) &&
88
+ value.length > 0 &&
89
+ value.every(
90
+ (item) =>
91
+ typeof item?.config?.path === 'string' &&
92
+ typeof item?.handler === 'function'
93
+ )
94
+ )
58
95
  }
59
96
 
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
66
- }
97
+ async function loadApp(path: string): Promise<WebhooksApp> {
98
+ const mod = await importModule(path)
99
+ const candidates = [mod.default, ...Object.values(mod)]
100
+ const app = candidates.find(isWebhooksApp)
101
+ if (app) return app
102
+ const webhooks = candidates.find(isWebhookArray)
103
+ if (webhooks) return createWebhooks(webhooks)
104
+ return fail(
105
+ `${path} exports neither a createWebhooks() app nor an array of webhooks`
106
+ )
107
+ }
108
+
109
+ async function signatureHeaders(rawBody: string) {
110
+ if (!values.sign) return {}
111
+ const scheme =
112
+ schemes[values.sign] ??
113
+ fail(
114
+ `Unknown scheme "${values.sign}", use one of: ${Object.keys(schemes).join(', ')}`
115
+ )
116
+ const name = values['secret-env'] ?? fail('--sign needs --secret-env <name>')
117
+ const secret = process.env[name] ?? fail(`${name} is not set`)
118
+ return scheme.sign(rawBody, secret)
67
119
  }
68
120
 
69
121
  async function main() {
@@ -71,13 +123,17 @@ async function main() {
71
123
  console.log(HELP)
72
124
  return
73
125
  }
126
+ for (const preload of values.preload ?? []) await importModule(preload)
74
127
  const app = await loadApp(modulePath)
75
128
 
76
129
  if (command === 'list') {
77
130
  for (const { config } of app.webhooks) {
78
- const validates = (['params', 'query', 'headers', 'body'] as const)
79
- .filter((key) => config[key])
80
- .join(', ')
131
+ const validates = [
132
+ ...(['params', 'query', 'headers', 'body'] as const).filter(
133
+ (key) => config[key]
134
+ ),
135
+ ...(config.verify ? ['signature'] : [])
136
+ ].join(', ')
81
137
  console.log(
82
138
  `${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
83
139
  )
@@ -88,21 +144,25 @@ async function main() {
88
144
 
89
145
  if (command === 'schema') {
90
146
  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) {
147
+ const descriptions = describeWebhooks(app)
148
+ const indexes = app.webhooks.flatMap((w, i) =>
149
+ w.config.path === path ? [i] : []
150
+ )
151
+ if (!indexes.length) fail(`No webhook at ${path}`)
152
+ for (const i of indexes) {
153
+ const { config } = app.webhooks[i]!
94
154
  console.log(`${config.method} ${config.path}`)
95
- for (const key of [
155
+ for (const part of [
96
156
  'params',
97
157
  'query',
98
158
  'headers',
99
159
  'body',
100
160
  'response'
101
161
  ] as const) {
102
- if (!config[key]) continue
103
- const json = jsonSchemaOf(config[key])
162
+ if (!config[part]) continue
163
+ const schema = descriptions[i]!.jsonSchema[part]
104
164
  console.log(
105
- ` ${key}: ${json ? JSON.stringify(json, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
165
+ ` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
106
166
  )
107
167
  }
108
168
  }
@@ -126,6 +186,11 @@ async function main() {
126
186
  headers.set('content-type', 'text/plain')
127
187
  }
128
188
  }
189
+ for (const [name, value] of Object.entries(
190
+ await signatureHeaders(values.body ?? '')
191
+ )) {
192
+ headers.set(name, value)
193
+ }
129
194
 
130
195
  const url = `${path.startsWith('/') ? path : `/${path}`}${values.query ? `?${values.query}` : ''}`
131
196
  const start = performance.now()