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/README.md +344 -91
- package/dist/cli.js +491 -20
- package/dist/index.d.ts +172 -18
- package/dist/index.js +123 -7
- package/dist/signatures.d.ts +53 -0
- package/dist/signatures.js +132 -0
- package/package.json +9 -3
- package/src/cli.ts +129 -25
- package/src/index.ts +366 -24
- package/src/signatures.ts +240 -0
package/dist/cli.js
CHANGED
|
@@ -1,35 +1,463 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
4
6
|
import { resolve } from "node:path";
|
|
5
7
|
import { pathToFileURL } from "node:url";
|
|
6
8
|
import { parseArgs } from "node:util";
|
|
9
|
+
|
|
10
|
+
// src/index.ts
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { HTTPException } from "hono/http-exception";
|
|
13
|
+
import { basePath } from "hono/route";
|
|
14
|
+
|
|
15
|
+
class WebhookValidationError extends HTTPException {
|
|
16
|
+
target;
|
|
17
|
+
issues;
|
|
18
|
+
constructor(target, issues) {
|
|
19
|
+
super(target === "signature" ? 401 : 400, {
|
|
20
|
+
message: `Invalid ${target}`
|
|
21
|
+
});
|
|
22
|
+
this.target = target;
|
|
23
|
+
this.issues = issues;
|
|
24
|
+
this.name = "WebhookValidationError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function makeDefineWebhook() {
|
|
28
|
+
return (config, handler) => ({
|
|
29
|
+
config,
|
|
30
|
+
handler
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function detectBodyType(contentType = "") {
|
|
34
|
+
if (/[/+]json\b/i.test(contentType))
|
|
35
|
+
return "json";
|
|
36
|
+
if (/x-www-form-urlencoded/i.test(contentType))
|
|
37
|
+
return "form";
|
|
38
|
+
return "text";
|
|
39
|
+
}
|
|
40
|
+
function parseBody(raw, type, contentType) {
|
|
41
|
+
if (!raw)
|
|
42
|
+
return;
|
|
43
|
+
const resolved = type === "auto" ? detectBodyType(contentType) : type;
|
|
44
|
+
if (resolved === "text")
|
|
45
|
+
return raw;
|
|
46
|
+
if (resolved === "form")
|
|
47
|
+
return Object.fromEntries(new URLSearchParams(raw));
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new WebhookValidationError("body", [{ message: "Malformed JSON" }]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function parse(schema, value, target) {
|
|
55
|
+
if (!schema)
|
|
56
|
+
return value;
|
|
57
|
+
const result = await schema["~standard"].validate(value);
|
|
58
|
+
if (result.issues)
|
|
59
|
+
throw new WebhookValidationError(target, result.issues);
|
|
60
|
+
return result.value;
|
|
61
|
+
}
|
|
62
|
+
function defaultErrorResponse(error, c, log) {
|
|
63
|
+
if (error instanceof WebhookValidationError) {
|
|
64
|
+
return c.json({ error: error.message, issues: error.issues }, error.status);
|
|
65
|
+
}
|
|
66
|
+
if (error instanceof HTTPException) {
|
|
67
|
+
return error.res ?? c.json({ error: error.message }, error.status);
|
|
68
|
+
}
|
|
69
|
+
if (log)
|
|
70
|
+
console.error(error);
|
|
71
|
+
return c.json({ error: "Internal Server Error" }, 500);
|
|
72
|
+
}
|
|
73
|
+
function report(c, pending) {
|
|
74
|
+
if (!(pending instanceof Promise))
|
|
75
|
+
return;
|
|
76
|
+
const handled = pending.catch((error) => console.error("hono-webhooks: onEvent failed", error));
|
|
77
|
+
try {
|
|
78
|
+
c.executionCtx.waitUntil(handled);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
function isDynamic(path) {
|
|
82
|
+
return path.includes(":") || path.includes("*");
|
|
83
|
+
}
|
|
84
|
+
function makeCreateWebhooks() {
|
|
85
|
+
return (webhooks, options = {}) => {
|
|
86
|
+
const app = new Hono;
|
|
87
|
+
const methodsByPath = new Map;
|
|
88
|
+
for (const { config } of webhooks) {
|
|
89
|
+
const methods = methodsByPath.get(config.path) ?? [];
|
|
90
|
+
if (methods.includes(config.method)) {
|
|
91
|
+
throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
|
|
92
|
+
}
|
|
93
|
+
methods.push(config.method);
|
|
94
|
+
methodsByPath.set(config.path, methods);
|
|
95
|
+
}
|
|
96
|
+
const handle = (route, run) => {
|
|
97
|
+
const execute = async (c) => {
|
|
98
|
+
const start = performance.now();
|
|
99
|
+
const base = basePath(c);
|
|
100
|
+
const event = {
|
|
101
|
+
c,
|
|
102
|
+
route,
|
|
103
|
+
method: c.req.method,
|
|
104
|
+
path: base === "/" ? c.req.path : c.req.path.slice(base.length) || "/",
|
|
105
|
+
status: 0,
|
|
106
|
+
durationMs: 0
|
|
107
|
+
};
|
|
108
|
+
let res;
|
|
109
|
+
try {
|
|
110
|
+
res = await run(c, event);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
event.error = error;
|
|
113
|
+
if (error instanceof WebhookValidationError) {
|
|
114
|
+
event.validation = { target: error.target, issues: error.issues };
|
|
115
|
+
}
|
|
116
|
+
res = await options.onError?.(error, c) ?? defaultErrorResponse(error, c, options.logErrors ?? true);
|
|
117
|
+
}
|
|
118
|
+
event.status = res.status;
|
|
119
|
+
event.durationMs = performance.now() - start;
|
|
120
|
+
if (options.onEvent)
|
|
121
|
+
report(c, options.onEvent(event));
|
|
122
|
+
return res;
|
|
123
|
+
};
|
|
124
|
+
const { around } = options;
|
|
125
|
+
return (c) => around ? around({ c, route, method: c.req.method }, () => execute(c)) : execute(c);
|
|
126
|
+
};
|
|
127
|
+
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
128
|
+
for (const { config, handler } of ordered) {
|
|
129
|
+
app.on(config.method, config.path, handle(config.path, async (c, event) => {
|
|
130
|
+
event.rawBody = await c.req.text();
|
|
131
|
+
if (config.verify && !await config.verify({
|
|
132
|
+
c,
|
|
133
|
+
rawBody: event.rawBody,
|
|
134
|
+
headers: c.req.header()
|
|
135
|
+
})) {
|
|
136
|
+
throw new WebhookValidationError("signature", [
|
|
137
|
+
{ message: "Signature does not match" }
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
event.params = await parse(config.params, c.req.param(), "params");
|
|
141
|
+
event.query = await parse(config.query, c.req.query(), "query");
|
|
142
|
+
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
143
|
+
event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
|
|
144
|
+
const result = await handler({
|
|
145
|
+
c,
|
|
146
|
+
params: event.params,
|
|
147
|
+
query: event.query,
|
|
148
|
+
headers: event.headers,
|
|
149
|
+
body: event.body,
|
|
150
|
+
rawBody: event.rawBody
|
|
151
|
+
});
|
|
152
|
+
if (result instanceof Response)
|
|
153
|
+
return result;
|
|
154
|
+
event.response = result;
|
|
155
|
+
if (config.response) {
|
|
156
|
+
const checked = await config.response["~standard"].validate(result);
|
|
157
|
+
if (checked.issues) {
|
|
158
|
+
event.validation = { target: "response", issues: checked.issues };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (result === undefined)
|
|
162
|
+
return c.body(null, 204);
|
|
163
|
+
if (typeof result === "string") {
|
|
164
|
+
return c.text(result, 200, {
|
|
165
|
+
"content-type": "text/plain; charset=UTF-8"
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return c.json(result);
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
for (const [path, methods] of methodsByPath) {
|
|
172
|
+
app.all(path, handle(path, async (c) => {
|
|
173
|
+
c.header("Allow", methods.join(", "));
|
|
174
|
+
throw new HTTPException(405, {
|
|
175
|
+
message: `Method ${c.req.method} not allowed`
|
|
176
|
+
});
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
app.all("*", handle(undefined, async () => {
|
|
180
|
+
throw new HTTPException(404, { message: "Route not found" });
|
|
181
|
+
}));
|
|
182
|
+
return Object.assign(app, { webhooks });
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
var defineWebhook = makeDefineWebhook();
|
|
186
|
+
var createWebhooks = makeCreateWebhooks();
|
|
187
|
+
function createWebhookFactory() {
|
|
188
|
+
return {
|
|
189
|
+
defineWebhook: makeDefineWebhook(),
|
|
190
|
+
createWebhooks: makeCreateWebhooks()
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function formatIssues(issues) {
|
|
194
|
+
return issues.map((issue) => {
|
|
195
|
+
const path = issuePath(issue);
|
|
196
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
197
|
+
}).join("; ");
|
|
198
|
+
}
|
|
199
|
+
function issuePath(issue) {
|
|
200
|
+
const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
201
|
+
return path || null;
|
|
202
|
+
}
|
|
203
|
+
function summarizeEvent(event) {
|
|
204
|
+
const { c, error, validation } = event;
|
|
205
|
+
return {
|
|
206
|
+
route: event.route ?? null,
|
|
207
|
+
method: event.method,
|
|
208
|
+
path: event.path,
|
|
209
|
+
status: event.status,
|
|
210
|
+
durationMs: Math.round(event.durationMs * 100) / 100,
|
|
211
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || c.req.header("cf-connecting-ip") || null,
|
|
212
|
+
userAgent: c.req.header("user-agent") ?? null,
|
|
213
|
+
params: event.params ?? null,
|
|
214
|
+
query: event.query ?? null,
|
|
215
|
+
body: event.body ?? null,
|
|
216
|
+
response: event.response ?? null,
|
|
217
|
+
error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
|
|
218
|
+
name: error.name,
|
|
219
|
+
message: error.message,
|
|
220
|
+
stack: error.stack ?? null
|
|
221
|
+
} : { name: "Error", message: String(error), stack: null },
|
|
222
|
+
validation: validation ? {
|
|
223
|
+
target: validation.target,
|
|
224
|
+
message: formatIssues(validation.issues),
|
|
225
|
+
issues: validation.issues.map((issue) => ({
|
|
226
|
+
message: issue.message,
|
|
227
|
+
path: issuePath(issue)
|
|
228
|
+
}))
|
|
229
|
+
} : null
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function toJsonSchema(schema, side, target) {
|
|
233
|
+
const props = schema?.["~standard"];
|
|
234
|
+
try {
|
|
235
|
+
return props?.jsonSchema?.[side]({ target }) ?? null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function describeWebhooks(source, { target = "draft-2020-12" } = {}) {
|
|
241
|
+
const webhooks = "webhooks" in source ? source.webhooks : source;
|
|
242
|
+
return webhooks.map(({ config }) => ({
|
|
243
|
+
method: config.method,
|
|
244
|
+
path: config.path,
|
|
245
|
+
description: config.description ?? null,
|
|
246
|
+
verified: Boolean(config.verify),
|
|
247
|
+
bodyType: config.bodyType ?? "auto",
|
|
248
|
+
jsonSchema: {
|
|
249
|
+
params: toJsonSchema(config.params, "input", target),
|
|
250
|
+
query: toJsonSchema(config.query, "input", target),
|
|
251
|
+
headers: toJsonSchema(config.headers, "input", target),
|
|
252
|
+
body: toJsonSchema(config.body, "input", target),
|
|
253
|
+
response: toJsonSchema(config.response, "output", target)
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
function fillPath(pattern, params) {
|
|
258
|
+
return pattern.replace(/\/:(\w+)(?:\{[^}]*\})?\??/g, (_, name) => {
|
|
259
|
+
const value = params[name];
|
|
260
|
+
return value === undefined ? "" : `/${encodeURIComponent(String(value))}`;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async function testWebhook(webhook, input = {}) {
|
|
264
|
+
const { config } = webhook;
|
|
265
|
+
const { body, sign, env, options } = input;
|
|
266
|
+
const headers = new Headers(input.headers);
|
|
267
|
+
let rawBody;
|
|
268
|
+
if (typeof body === "string") {
|
|
269
|
+
rawBody = body;
|
|
270
|
+
if (!headers.has("content-type"))
|
|
271
|
+
headers.set("content-type", "text/plain");
|
|
272
|
+
} else if (body !== undefined && config.bodyType === "form") {
|
|
273
|
+
rawBody = new URLSearchParams(body).toString();
|
|
274
|
+
if (!headers.has("content-type")) {
|
|
275
|
+
headers.set("content-type", "application/x-www-form-urlencoded");
|
|
276
|
+
}
|
|
277
|
+
} else if (body !== undefined) {
|
|
278
|
+
rawBody = JSON.stringify(body);
|
|
279
|
+
if (!headers.has("content-type")) {
|
|
280
|
+
headers.set("content-type", "application/json");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const [name, value] of Object.entries(await sign?.(rawBody ?? "") ?? {})) {
|
|
284
|
+
headers.set(name, value);
|
|
285
|
+
}
|
|
286
|
+
const path = input.path ?? fillPath(config.path, input.params ?? {});
|
|
287
|
+
const query = new URLSearchParams(input.query).toString();
|
|
288
|
+
return createWebhooks([webhook], options).request(`${path || "/"}${query ? `?${query}` : ""}`, { method: config.method, headers, body: rawBody }, env);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/signatures.ts
|
|
292
|
+
var encoder = new TextEncoder;
|
|
293
|
+
function hmacKey(secret, hash) {
|
|
294
|
+
if (!secret.length) {
|
|
295
|
+
throw new Error("hono-webhooks: the signing secret is empty");
|
|
296
|
+
}
|
|
297
|
+
return crypto.subtle.importKey("raw", typeof secret === "string" ? encoder.encode(secret) : secret, { name: "HMAC", hash }, false, ["sign", "verify"]);
|
|
298
|
+
}
|
|
299
|
+
async function hmacSign(secret, payload, hash = "SHA-256") {
|
|
300
|
+
const key = await hmacKey(secret, hash);
|
|
301
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
302
|
+
}
|
|
303
|
+
async function hmacVerify(secret, payload, signature, hash = "SHA-256") {
|
|
304
|
+
const key = await hmacKey(secret, hash);
|
|
305
|
+
return crypto.subtle.verify("HMAC", key, signature, encoder.encode(payload));
|
|
306
|
+
}
|
|
307
|
+
var toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
308
|
+
var toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
|
309
|
+
function fromHex(hex) {
|
|
310
|
+
if (hex.length % 2 || /[^0-9a-f]/i.test(hex))
|
|
311
|
+
return;
|
|
312
|
+
return Uint8Array.from(hex.match(/../g) ?? [], (h) => Number.parseInt(h, 16));
|
|
313
|
+
}
|
|
314
|
+
function fromBase64(base64) {
|
|
315
|
+
try {
|
|
316
|
+
return Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0));
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
var resolveSecret = async (secret, c) => typeof secret === "function" ? secret(c) : secret;
|
|
322
|
+
function hmac(options) {
|
|
323
|
+
const { header, encoding, prefix = "", hash = "SHA-256" } = options;
|
|
324
|
+
const name = header.toLowerCase();
|
|
325
|
+
return {
|
|
326
|
+
verify: (secret) => async ({ c, rawBody, headers }) => {
|
|
327
|
+
const value = headers[name];
|
|
328
|
+
if (!value?.startsWith(prefix))
|
|
329
|
+
return false;
|
|
330
|
+
const encoded = value.slice(prefix.length);
|
|
331
|
+
const signature = encoding === "hex" ? fromHex(encoded) : fromBase64(encoded);
|
|
332
|
+
if (!signature)
|
|
333
|
+
return false;
|
|
334
|
+
return hmacVerify(await resolveSecret(secret, c), rawBody, signature, hash);
|
|
335
|
+
},
|
|
336
|
+
async sign(rawBody, secret) {
|
|
337
|
+
const signature = await hmacSign(secret, rawBody, hash);
|
|
338
|
+
return {
|
|
339
|
+
[name]: `${prefix}${encoding === "hex" ? toHex(signature) : toBase64(signature)}`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
var github = hmac({
|
|
345
|
+
header: "x-hub-signature-256",
|
|
346
|
+
encoding: "hex",
|
|
347
|
+
prefix: "sha256="
|
|
348
|
+
});
|
|
349
|
+
var shopify = hmac({
|
|
350
|
+
header: "x-shopify-hmac-sha256",
|
|
351
|
+
encoding: "base64"
|
|
352
|
+
});
|
|
353
|
+
var stripe = {
|
|
354
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
|
|
355
|
+
const parts = (headers["stripe-signature"] ?? "").split(",").map((part) => part.split("="));
|
|
356
|
+
const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "";
|
|
357
|
+
if (!/^\d+$/.test(timestamp))
|
|
358
|
+
return false;
|
|
359
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
const key = await resolveSecret(secret, c);
|
|
363
|
+
for (const [k, v] of parts) {
|
|
364
|
+
const signature = k === "v1" && v ? fromHex(v) : undefined;
|
|
365
|
+
if (signature && await hmacVerify(key, `${timestamp}.${rawBody}`, signature)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
},
|
|
371
|
+
async sign(rawBody, secret, { timestamp } = {}) {
|
|
372
|
+
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
373
|
+
const signature = await hmacSign(secret, `${t}.${rawBody}`);
|
|
374
|
+
return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
function svixKey(secret) {
|
|
378
|
+
const key = fromBase64(secret.replace(/^whsec_/, ""));
|
|
379
|
+
if (!key?.length) {
|
|
380
|
+
throw new Error("hono-webhooks: the Svix secret must be whsec_<base64>");
|
|
381
|
+
}
|
|
382
|
+
return key;
|
|
383
|
+
}
|
|
384
|
+
var svix = {
|
|
385
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
|
|
386
|
+
const prefix = headers["svix-id"] ? "svix" : "webhook";
|
|
387
|
+
const id = headers[`${prefix}-id`];
|
|
388
|
+
const timestamp = headers[`${prefix}-timestamp`] ?? "";
|
|
389
|
+
const signatures = headers[`${prefix}-signature`];
|
|
390
|
+
if (!id || !signatures || !/^\d+$/.test(timestamp))
|
|
391
|
+
return false;
|
|
392
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const key = svixKey(await resolveSecret(secret, c));
|
|
396
|
+
for (const entry of signatures.split(" ")) {
|
|
397
|
+
const [version, encoded] = entry.split(",");
|
|
398
|
+
const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
|
|
399
|
+
if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
},
|
|
405
|
+
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
406
|
+
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
407
|
+
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
408
|
+
const signature = await hmacSign(svixKey(secret), `${msgId}.${t}.${rawBody}`);
|
|
409
|
+
return {
|
|
410
|
+
"svix-id": msgId,
|
|
411
|
+
"svix-timestamp": String(t),
|
|
412
|
+
"svix-signature": `v1,${toBase64(signature)}`
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// src/cli.ts
|
|
418
|
+
var schemes = {
|
|
419
|
+
github,
|
|
420
|
+
shopify,
|
|
421
|
+
stripe,
|
|
422
|
+
svix
|
|
423
|
+
};
|
|
7
424
|
var HELP = `Usage: hono-webhooks <module> <command> [options]
|
|
8
425
|
|
|
9
|
-
<module> exports
|
|
426
|
+
<module> exports a createWebhooks() app or an array of webhooks, as default
|
|
427
|
+
or named export.
|
|
10
428
|
|
|
11
429
|
Commands:
|
|
12
430
|
list List the webhooks
|
|
13
431
|
schema <path> Print the JSON Schemas of the webhooks at <path>
|
|
14
432
|
test <method> <path> Send a request through the webhooks app
|
|
15
433
|
|
|
434
|
+
Options:
|
|
435
|
+
-r, --preload <module> Import a module first (e.g. to load secrets), repeatable
|
|
436
|
+
|
|
16
437
|
Options for test:
|
|
17
438
|
-b, --body <text> Request body (JSON sets content-type: application/json)
|
|
18
439
|
-q, --query <params> Query string, e.g. "a=1&b=2"
|
|
19
440
|
-H, --header <k: v> Request header, repeatable
|
|
441
|
+
-s, --sign <scheme> Sign the body: ${Object.keys(schemes).join(", ")}
|
|
442
|
+
-e, --secret-env <name> Environment variable holding the signing secret
|
|
20
443
|
|
|
21
444
|
Examples:
|
|
22
445
|
hono-webhooks ./src/webhooks.ts list
|
|
23
446
|
hono-webhooks ./src/webhooks.ts schema /orders/:id
|
|
24
447
|
hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
|
|
448
|
+
hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
|
|
449
|
+
-r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET
|
|
25
450
|
`;
|
|
26
451
|
var { values, positionals } = parseArgs({
|
|
27
452
|
allowPositionals: true,
|
|
28
453
|
options: {
|
|
29
454
|
help: { type: "boolean", short: "h" },
|
|
455
|
+
preload: { type: "string", short: "r", multiple: true },
|
|
30
456
|
body: { type: "string", short: "b" },
|
|
31
457
|
query: { type: "string", short: "q" },
|
|
32
|
-
header: { type: "string", short: "H", multiple: true }
|
|
458
|
+
header: { type: "string", short: "H", multiple: true },
|
|
459
|
+
sign: { type: "string", short: "s" },
|
|
460
|
+
"secret-env": { type: "string", short: "e" }
|
|
33
461
|
}
|
|
34
462
|
});
|
|
35
463
|
var [modulePath, command, ...args] = positionals;
|
|
@@ -37,31 +465,69 @@ function fail(message) {
|
|
|
37
465
|
console.error(message);
|
|
38
466
|
process.exit(1);
|
|
39
467
|
}
|
|
468
|
+
var importModule = (specifier) => existsSync(resolve(specifier)) ? import(pathToFileURL(resolve(specifier)).href) : import(specifier);
|
|
40
469
|
function isWebhooksApp(value) {
|
|
41
470
|
return typeof value === "object" && value !== null && Array.isArray(value.webhooks) && typeof value.request === "function";
|
|
42
471
|
}
|
|
472
|
+
function isWebhookArray(value) {
|
|
473
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item?.config?.path === "string" && typeof item?.handler === "function");
|
|
474
|
+
}
|
|
43
475
|
async function loadApp(path) {
|
|
44
|
-
const mod = await
|
|
45
|
-
const
|
|
46
|
-
|
|
476
|
+
const mod = await importModule(path);
|
|
477
|
+
const candidates = [mod.default, ...Object.values(mod)];
|
|
478
|
+
const app = candidates.find(isWebhooksApp);
|
|
479
|
+
if (app)
|
|
480
|
+
return app;
|
|
481
|
+
const webhooks = candidates.find(isWebhookArray);
|
|
482
|
+
if (webhooks)
|
|
483
|
+
return createWebhooks(webhooks);
|
|
484
|
+
return fail(`${path} exports neither a createWebhooks() app nor an array of webhooks`);
|
|
47
485
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
486
|
+
var BUN_ONLY_ERRORS = new Set([
|
|
487
|
+
"ERR_MODULE_NOT_FOUND",
|
|
488
|
+
"ERR_UNKNOWN_FILE_EXTENSION",
|
|
489
|
+
"ERR_UNSUPPORTED_DIR_IMPORT",
|
|
490
|
+
"ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX",
|
|
491
|
+
"ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING"
|
|
492
|
+
]);
|
|
493
|
+
function rerunUnderBun(error) {
|
|
494
|
+
const code = error.code;
|
|
495
|
+
if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code))
|
|
496
|
+
throw error;
|
|
497
|
+
console.error(`Node can't load the module (${code}), trying Bun`);
|
|
498
|
+
const run = spawnSync("bun", process.argv.slice(1), { stdio: "inherit" });
|
|
499
|
+
if (run.error) {
|
|
500
|
+
fail("Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...");
|
|
54
501
|
}
|
|
502
|
+
process.exit(run.status ?? 1);
|
|
503
|
+
}
|
|
504
|
+
async function signatureHeaders(rawBody) {
|
|
505
|
+
if (!values.sign)
|
|
506
|
+
return {};
|
|
507
|
+
const scheme = schemes[values.sign] ?? fail(`Unknown scheme "${values.sign}", use one of: ${Object.keys(schemes).join(", ")}`);
|
|
508
|
+
const name = values["secret-env"] ?? fail("--sign needs --secret-env <name>");
|
|
509
|
+
const secret = process.env[name] ?? fail(`${name} is not set`);
|
|
510
|
+
return scheme.sign(rawBody, secret);
|
|
55
511
|
}
|
|
56
512
|
async function main() {
|
|
57
513
|
if (values.help || !modulePath || !command) {
|
|
58
514
|
console.log(HELP);
|
|
59
515
|
return;
|
|
60
516
|
}
|
|
61
|
-
|
|
517
|
+
let app;
|
|
518
|
+
try {
|
|
519
|
+
for (const preload of values.preload ?? [])
|
|
520
|
+
await importModule(preload);
|
|
521
|
+
app = await loadApp(modulePath);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
rerunUnderBun(error);
|
|
524
|
+
}
|
|
62
525
|
if (command === "list") {
|
|
63
526
|
for (const { config } of app.webhooks) {
|
|
64
|
-
const validates = [
|
|
527
|
+
const validates = [
|
|
528
|
+
...["params", "query", "headers", "body"].filter((key) => config[key]),
|
|
529
|
+
...config.verify ? ["signature"] : []
|
|
530
|
+
].join(", ");
|
|
65
531
|
console.log(`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ""}`);
|
|
66
532
|
if (validates)
|
|
67
533
|
console.log(` validates ${validates}`);
|
|
@@ -70,22 +536,24 @@ async function main() {
|
|
|
70
536
|
}
|
|
71
537
|
if (command === "schema") {
|
|
72
538
|
const [path] = args;
|
|
73
|
-
const
|
|
74
|
-
|
|
539
|
+
const descriptions = describeWebhooks(app);
|
|
540
|
+
const indexes = app.webhooks.flatMap((w, i) => w.config.path === path ? [i] : []);
|
|
541
|
+
if (!indexes.length)
|
|
75
542
|
fail(`No webhook at ${path}`);
|
|
76
|
-
for (const
|
|
543
|
+
for (const i of indexes) {
|
|
544
|
+
const { config } = app.webhooks[i];
|
|
77
545
|
console.log(`${config.method} ${config.path}`);
|
|
78
|
-
for (const
|
|
546
|
+
for (const part of [
|
|
79
547
|
"params",
|
|
80
548
|
"query",
|
|
81
549
|
"headers",
|
|
82
550
|
"body",
|
|
83
551
|
"response"
|
|
84
552
|
]) {
|
|
85
|
-
if (!config[
|
|
553
|
+
if (!config[part])
|
|
86
554
|
continue;
|
|
87
|
-
const
|
|
88
|
-
console.log(` ${
|
|
555
|
+
const schema = descriptions[i].jsonSchema[part];
|
|
556
|
+
console.log(` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll(`
|
|
89
557
|
`, `
|
|
90
558
|
`) : "(no JSON Schema support)"}`);
|
|
91
559
|
}
|
|
@@ -111,6 +579,9 @@ async function main() {
|
|
|
111
579
|
headers.set("content-type", "text/plain");
|
|
112
580
|
}
|
|
113
581
|
}
|
|
582
|
+
for (const [name, value] of Object.entries(await signatureHeaders(values.body ?? ""))) {
|
|
583
|
+
headers.set(name, value);
|
|
584
|
+
}
|
|
114
585
|
const url = `${path.startsWith("/") ? path : `/${path}`}${values.query ? `?${values.query}` : ""}`;
|
|
115
586
|
const start = performance.now();
|
|
116
587
|
const res = await app.request(url, {
|