hono-webhooks 0.1.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/LICENSE +21 -0
- package/README.md +587 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +174 -0
- package/package.json +74 -0
- package/src/cli.ts +151 -0
- package/src/index.ts +418 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
var HELP = `Usage: hono-webhooks <module> <command> [options]
|
|
8
|
+
|
|
9
|
+
<module> exports the result of createWebhooks(), as default or named export.
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
list List the webhooks
|
|
13
|
+
schema <path> Print the JSON Schemas of the webhooks at <path>
|
|
14
|
+
test <method> <path> Send a request through the webhooks app
|
|
15
|
+
|
|
16
|
+
Options for test:
|
|
17
|
+
-b, --body <text> Request body (JSON sets content-type: application/json)
|
|
18
|
+
-q, --query <params> Query string, e.g. "a=1&b=2"
|
|
19
|
+
-H, --header <k: v> Request header, repeatable
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
hono-webhooks ./src/webhooks.ts list
|
|
23
|
+
hono-webhooks ./src/webhooks.ts schema /orders/:id
|
|
24
|
+
hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
|
|
25
|
+
`;
|
|
26
|
+
var { values, positionals } = parseArgs({
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
options: {
|
|
29
|
+
help: { type: "boolean", short: "h" },
|
|
30
|
+
body: { type: "string", short: "b" },
|
|
31
|
+
query: { type: "string", short: "q" },
|
|
32
|
+
header: { type: "string", short: "H", multiple: true }
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
var [modulePath, command, ...args] = positionals;
|
|
36
|
+
function fail(message) {
|
|
37
|
+
console.error(message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
function isWebhooksApp(value) {
|
|
41
|
+
return typeof value === "object" && value !== null && Array.isArray(value.webhooks) && typeof value.request === "function";
|
|
42
|
+
}
|
|
43
|
+
async function loadApp(path) {
|
|
44
|
+
const mod = await import(pathToFileURL(resolve(path)).href);
|
|
45
|
+
const app = [mod.default, ...Object.values(mod)].find(isWebhooksApp);
|
|
46
|
+
return app ?? fail(`${path} does not export a createWebhooks() app`);
|
|
47
|
+
}
|
|
48
|
+
function jsonSchemaOf(schema) {
|
|
49
|
+
const props = schema["~standard"];
|
|
50
|
+
try {
|
|
51
|
+
return props?.jsonSchema?.input({ target: "draft-2020-12" });
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function main() {
|
|
57
|
+
if (values.help || !modulePath || !command) {
|
|
58
|
+
console.log(HELP);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const app = await loadApp(modulePath);
|
|
62
|
+
if (command === "list") {
|
|
63
|
+
for (const { config } of app.webhooks) {
|
|
64
|
+
const validates = ["params", "query", "headers", "body"].filter((key) => config[key]).join(", ");
|
|
65
|
+
console.log(`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ""}`);
|
|
66
|
+
if (validates)
|
|
67
|
+
console.log(` validates ${validates}`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (command === "schema") {
|
|
72
|
+
const [path] = args;
|
|
73
|
+
const matches = app.webhooks.filter((w) => w.config.path === path);
|
|
74
|
+
if (!matches.length)
|
|
75
|
+
fail(`No webhook at ${path}`);
|
|
76
|
+
for (const { config } of matches) {
|
|
77
|
+
console.log(`${config.method} ${config.path}`);
|
|
78
|
+
for (const key of [
|
|
79
|
+
"params",
|
|
80
|
+
"query",
|
|
81
|
+
"headers",
|
|
82
|
+
"body",
|
|
83
|
+
"response"
|
|
84
|
+
]) {
|
|
85
|
+
if (!config[key])
|
|
86
|
+
continue;
|
|
87
|
+
const json = jsonSchemaOf(config[key]);
|
|
88
|
+
console.log(` ${key}: ${json ? JSON.stringify(json, null, 2).replaceAll(`
|
|
89
|
+
`, `
|
|
90
|
+
`) : "(no JSON Schema support)"}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === "test") {
|
|
96
|
+
const [method, path] = args;
|
|
97
|
+
if (!method || !path)
|
|
98
|
+
fail("Usage: test <method> <path>");
|
|
99
|
+
const headers = new Headers;
|
|
100
|
+
for (const header of values.header ?? []) {
|
|
101
|
+
const at = header.indexOf(":");
|
|
102
|
+
if (at < 1)
|
|
103
|
+
fail(`Header must look like "name: value", got "${header}"`);
|
|
104
|
+
headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim());
|
|
105
|
+
}
|
|
106
|
+
if (values.body && !headers.has("content-type")) {
|
|
107
|
+
try {
|
|
108
|
+
JSON.parse(values.body);
|
|
109
|
+
headers.set("content-type", "application/json");
|
|
110
|
+
} catch {
|
|
111
|
+
headers.set("content-type", "text/plain");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const url = `${path.startsWith("/") ? path : `/${path}`}${values.query ? `?${values.query}` : ""}`;
|
|
115
|
+
const start = performance.now();
|
|
116
|
+
const res = await app.request(url, {
|
|
117
|
+
method: method.toUpperCase(),
|
|
118
|
+
headers,
|
|
119
|
+
body: values.body
|
|
120
|
+
});
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
let body = text;
|
|
123
|
+
try {
|
|
124
|
+
body = JSON.stringify(JSON.parse(text), null, 2);
|
|
125
|
+
} catch {}
|
|
126
|
+
console.log(`${res.status} (${(performance.now() - start).toFixed(1)}ms)`);
|
|
127
|
+
if (body)
|
|
128
|
+
console.log(body);
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
fail(`Unknown command: ${command}
|
|
134
|
+
|
|
135
|
+
${HELP}`);
|
|
136
|
+
}
|
|
137
|
+
await main();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import { type Context, type Env, Hono } from 'hono';
|
|
3
|
+
import { HTTPException } from 'hono/http-exception';
|
|
4
|
+
import type { ParamKeys, ParamKeyToRecord } from 'hono/types';
|
|
5
|
+
export type { StandardSchemaV1 };
|
|
6
|
+
/** HEAD is not listed: Hono answers HEAD requests with the matching GET route. */
|
|
7
|
+
export type WebhookMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
|
8
|
+
/**
|
|
9
|
+
* How the request body is parsed before validation.
|
|
10
|
+
* - `auto` (default): by `Content-Type`. JSON types are parsed as JSON,
|
|
11
|
+
* `application/x-www-form-urlencoded` as a string record, anything else as text.
|
|
12
|
+
* - `json`, `form`, `text`: always parse that way, whatever the header says.
|
|
13
|
+
*/
|
|
14
|
+
export type BodyType = 'auto' | 'json' | 'form' | 'text';
|
|
15
|
+
/** The part of the request (or the response) a schema validates. */
|
|
16
|
+
export type ValidationTarget = 'params' | 'query' | 'headers' | 'body' | 'response';
|
|
17
|
+
type Schema = StandardSchemaV1;
|
|
18
|
+
type Out<S, Fallback> = S extends Schema ? StandardSchemaV1.InferOutput<S> : Fallback;
|
|
19
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
20
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
21
|
+
/**
|
|
22
|
+
* Params typed from the path string: `/users/:id` gives `{ id: string }`,
|
|
23
|
+
* `/files/:name?` gives `{ name: string | undefined }`.
|
|
24
|
+
*/
|
|
25
|
+
export type PathParams<P extends string> = [ParamKeys<P>] extends [never] ? Record<string, never> : UnionToIntersection<ParamKeyToRecord<ParamKeys<P>>>;
|
|
26
|
+
export interface WebhookConfig<P extends string = string, TParams extends Schema | undefined = Schema | undefined, TQuery extends Schema | undefined = Schema | undefined, THeaders extends Schema | undefined = Schema | undefined, TBody extends Schema | undefined = Schema | undefined, TResponse extends Schema | undefined = Schema | undefined> {
|
|
27
|
+
method: WebhookMethod;
|
|
28
|
+
/** Hono path pattern, relative to where the webhooks are mounted. */
|
|
29
|
+
path: P;
|
|
30
|
+
params?: TParams;
|
|
31
|
+
query?: TQuery;
|
|
32
|
+
/** Header names are lower-case. */
|
|
33
|
+
headers?: THeaders;
|
|
34
|
+
body?: TBody;
|
|
35
|
+
/**
|
|
36
|
+
* Checks what the handler returns. A mismatch is reported on
|
|
37
|
+
* `WebhookEvent.validation` and the response is still sent.
|
|
38
|
+
*/
|
|
39
|
+
response?: TResponse;
|
|
40
|
+
/** @default 'auto' */
|
|
41
|
+
bodyType?: BodyType;
|
|
42
|
+
/** Shown by the CLI. */
|
|
43
|
+
description?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface WebhookInput<E extends Env, P extends string, TParams, TQuery, THeaders, TBody> {
|
|
46
|
+
/** The Hono context: `c.env`, `c.var`, `c.header()`, `c.req.raw`, ... */
|
|
47
|
+
c: Context<E, P>;
|
|
48
|
+
params: Out<TParams, PathParams<P>>;
|
|
49
|
+
query: Out<TQuery, Record<string, string>>;
|
|
50
|
+
headers: Out<THeaders, Record<string, string>>;
|
|
51
|
+
/** `undefined` for an empty body when there is no body schema. */
|
|
52
|
+
body: Out<TBody, unknown>;
|
|
53
|
+
/** The body exactly as received, for signature verification. */
|
|
54
|
+
rawBody: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What a handler may return: a `Response` is sent as is, `undefined` becomes
|
|
58
|
+
* `204 No Content`, a string is sent as text and anything else as JSON.
|
|
59
|
+
*/
|
|
60
|
+
export type WebhookResult<TResponse> = MaybePromise<Response | (TResponse extends Schema ? Out<TResponse, never> : unknown)>;
|
|
61
|
+
export interface Webhook<E extends Env = any, P extends string = string, TParams extends Schema | undefined = any, TQuery extends Schema | undefined = any, THeaders extends Schema | undefined = any, TBody extends Schema | undefined = any, TResponse extends Schema | undefined = any> {
|
|
62
|
+
config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
63
|
+
handler(input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>): WebhookResult<TResponse>;
|
|
64
|
+
}
|
|
65
|
+
/** Reported once per request that reaches the webhooks, after the response is built. */
|
|
66
|
+
export interface WebhookEvent<E extends Env = any> {
|
|
67
|
+
c: Context<E>;
|
|
68
|
+
/** The matched path pattern, e.g. `/users/:id`. `undefined` for a 404. */
|
|
69
|
+
route: string | undefined;
|
|
70
|
+
method: string;
|
|
71
|
+
/** The requested path. */
|
|
72
|
+
path: string;
|
|
73
|
+
status: number;
|
|
74
|
+
durationMs: number;
|
|
75
|
+
/** Each part is set once it has been validated. */
|
|
76
|
+
params?: unknown;
|
|
77
|
+
query?: unknown;
|
|
78
|
+
headers?: unknown;
|
|
79
|
+
body?: unknown;
|
|
80
|
+
rawBody?: string;
|
|
81
|
+
/** What the handler returned, unless it returned a `Response`. */
|
|
82
|
+
response?: unknown;
|
|
83
|
+
/** Anything thrown: a `WebhookValidationError`, an `HTTPException` or a handler error. */
|
|
84
|
+
error?: unknown;
|
|
85
|
+
/** Set when validation failed, including a response that failed its schema. */
|
|
86
|
+
validation?: {
|
|
87
|
+
target: ValidationTarget;
|
|
88
|
+
issues: readonly StandardSchemaV1.Issue[];
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export interface WebhooksOptions<E extends Env = any> {
|
|
92
|
+
/**
|
|
93
|
+
* Called after every request: log it, store it, record metrics. Not
|
|
94
|
+
* awaited; a returned promise is handed to `executionCtx.waitUntil` when
|
|
95
|
+
* the runtime has one, and its rejection is logged.
|
|
96
|
+
*/
|
|
97
|
+
onEvent?: (event: WebhookEvent<E>) => MaybePromise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Turns an error into a response. Return nothing to use the default:
|
|
100
|
+
* `400 { error, issues }` for validation, the status of an `HTTPException`,
|
|
101
|
+
* and `500 { error: 'Internal Server Error' }` for anything else.
|
|
102
|
+
*/
|
|
103
|
+
onError?: (error: unknown, c: Context<E>) => MaybePromise<Response | void>;
|
|
104
|
+
}
|
|
105
|
+
export type WebhooksApp<E extends Env = any> = Hono<E> & {
|
|
106
|
+
/** The definitions, in the order they were passed. */
|
|
107
|
+
readonly webhooks: readonly Webhook<E>[];
|
|
108
|
+
};
|
|
109
|
+
/** Thrown when a request part fails its schema. Answers 400 by default. */
|
|
110
|
+
export declare class WebhookValidationError extends HTTPException {
|
|
111
|
+
readonly target: Exclude<ValidationTarget, 'response'>;
|
|
112
|
+
readonly issues: readonly StandardSchemaV1.Issue[];
|
|
113
|
+
constructor(target: Exclude<ValidationTarget, 'response'>, issues: readonly StandardSchemaV1.Issue[]);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Defines one webhook. Types for `params`, `query`, `headers`, `body` and the
|
|
117
|
+
* return value come from the schemas; `params` falls back to the path.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* defineWebhook(
|
|
121
|
+
* { method: 'POST', path: '/orders/:id', body: z.object({ total: z.number() }) },
|
|
122
|
+
* async ({ params, body }) => ({ id: params.id, total: body.total })
|
|
123
|
+
* )
|
|
124
|
+
*/
|
|
125
|
+
export declare const defineWebhook: <const P extends string, TParams extends Schema | undefined = undefined, TQuery extends Schema | undefined = undefined, THeaders extends Schema | undefined = undefined, TBody extends Schema | undefined = undefined, TResponse extends Schema | undefined = undefined>(config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>, handler: (input: WebhookInput<any, P, TParams, TQuery, THeaders, TBody>) => WebhookResult<TResponse>) => Webhook<any, P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
126
|
+
/**
|
|
127
|
+
* Builds a Hono app that serves the webhooks. Mount it under a prefix:
|
|
128
|
+
* `app.route('/webhooks', createWebhooks([...]))`. Unknown paths below the
|
|
129
|
+
* prefix answer 404, known paths with the wrong method answer 405.
|
|
130
|
+
*/
|
|
131
|
+
export declare const createWebhooks: (webhooks: readonly Webhook<any, string, any, any, any, any, any>[], options?: WebhooksOptions<any>) => WebhooksApp<any>;
|
|
132
|
+
/**
|
|
133
|
+
* `defineWebhook` and `createWebhooks` bound to your Hono `Env`, so handlers
|
|
134
|
+
* see typed `c.env` and `c.var`.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* export const { defineWebhook, createWebhooks } =
|
|
138
|
+
* createWebhookFactory<{ Bindings: { STRIPE_SECRET: string } }>()
|
|
139
|
+
*/
|
|
140
|
+
export declare function createWebhookFactory<E extends Env = any>(): {
|
|
141
|
+
defineWebhook: <const P extends string, TParams extends Schema | undefined = undefined, TQuery extends Schema | undefined = undefined, THeaders extends Schema | undefined = undefined, TBody extends Schema | undefined = undefined, TResponse extends Schema | undefined = undefined>(config: WebhookConfig<P, TParams, TQuery, THeaders, TBody, TResponse>, handler: (input: WebhookInput<E, P, TParams, TQuery, THeaders, TBody>) => WebhookResult<TResponse>) => Webhook<E, P, TParams, TQuery, THeaders, TBody, TResponse>;
|
|
142
|
+
createWebhooks: (webhooks: readonly Webhook<E, string, any, any, any, any, any>[], options?: WebhooksOptions<E>) => WebhooksApp<E>;
|
|
143
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
|
|
5
|
+
class WebhookValidationError extends HTTPException {
|
|
6
|
+
target;
|
|
7
|
+
issues;
|
|
8
|
+
constructor(target, issues) {
|
|
9
|
+
super(400, { message: `Invalid ${target}` });
|
|
10
|
+
this.target = target;
|
|
11
|
+
this.issues = issues;
|
|
12
|
+
this.name = "WebhookValidationError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function makeDefineWebhook() {
|
|
16
|
+
return (config, handler) => ({
|
|
17
|
+
config,
|
|
18
|
+
handler
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function detectBodyType(contentType = "") {
|
|
22
|
+
if (/[/+]json\b/i.test(contentType))
|
|
23
|
+
return "json";
|
|
24
|
+
if (/x-www-form-urlencoded/i.test(contentType))
|
|
25
|
+
return "form";
|
|
26
|
+
return "text";
|
|
27
|
+
}
|
|
28
|
+
function parseBody(raw, type, contentType) {
|
|
29
|
+
if (!raw)
|
|
30
|
+
return;
|
|
31
|
+
const resolved = type === "auto" ? detectBodyType(contentType) : type;
|
|
32
|
+
if (resolved === "text")
|
|
33
|
+
return raw;
|
|
34
|
+
if (resolved === "form")
|
|
35
|
+
return Object.fromEntries(new URLSearchParams(raw));
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new WebhookValidationError("body", [{ message: "Malformed JSON" }]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function parse(schema, value, target) {
|
|
43
|
+
if (!schema)
|
|
44
|
+
return value;
|
|
45
|
+
const result = await schema["~standard"].validate(value);
|
|
46
|
+
if (result.issues)
|
|
47
|
+
throw new WebhookValidationError(target, result.issues);
|
|
48
|
+
return result.value;
|
|
49
|
+
}
|
|
50
|
+
function defaultErrorResponse(error, c, log) {
|
|
51
|
+
if (error instanceof WebhookValidationError) {
|
|
52
|
+
return c.json({ error: error.message, issues: error.issues }, 400);
|
|
53
|
+
}
|
|
54
|
+
if (error instanceof HTTPException) {
|
|
55
|
+
return error.res ?? c.json({ error: error.message }, error.status);
|
|
56
|
+
}
|
|
57
|
+
if (log)
|
|
58
|
+
console.error(error);
|
|
59
|
+
return c.json({ error: "Internal Server Error" }, 500);
|
|
60
|
+
}
|
|
61
|
+
function report(c, pending) {
|
|
62
|
+
if (!(pending instanceof Promise))
|
|
63
|
+
return;
|
|
64
|
+
const handled = pending.catch((error) => console.error("hono-webhooks: onEvent failed", error));
|
|
65
|
+
try {
|
|
66
|
+
c.executionCtx.waitUntil(handled);
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
function isDynamic(path) {
|
|
70
|
+
return path.includes(":") || path.includes("*");
|
|
71
|
+
}
|
|
72
|
+
function makeCreateWebhooks() {
|
|
73
|
+
return (webhooks, options = {}) => {
|
|
74
|
+
const app = new Hono;
|
|
75
|
+
const methodsByPath = new Map;
|
|
76
|
+
for (const { config } of webhooks) {
|
|
77
|
+
const methods = methodsByPath.get(config.path) ?? [];
|
|
78
|
+
if (methods.includes(config.method)) {
|
|
79
|
+
throw new Error(`hono-webhooks: ${config.method} ${config.path} is defined twice`);
|
|
80
|
+
}
|
|
81
|
+
methods.push(config.method);
|
|
82
|
+
methodsByPath.set(config.path, methods);
|
|
83
|
+
}
|
|
84
|
+
const handle = (route, run) => {
|
|
85
|
+
return async (c) => {
|
|
86
|
+
const start = performance.now();
|
|
87
|
+
const event = {
|
|
88
|
+
c,
|
|
89
|
+
route,
|
|
90
|
+
method: c.req.method,
|
|
91
|
+
path: c.req.path,
|
|
92
|
+
status: 0,
|
|
93
|
+
durationMs: 0
|
|
94
|
+
};
|
|
95
|
+
let res;
|
|
96
|
+
try {
|
|
97
|
+
res = await run(c, event);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
event.error = error;
|
|
100
|
+
if (error instanceof WebhookValidationError) {
|
|
101
|
+
event.validation = { target: error.target, issues: error.issues };
|
|
102
|
+
}
|
|
103
|
+
res = await options.onError?.(error, c) ?? defaultErrorResponse(error, c, !options.onError);
|
|
104
|
+
}
|
|
105
|
+
event.status = res.status;
|
|
106
|
+
event.durationMs = performance.now() - start;
|
|
107
|
+
if (options.onEvent)
|
|
108
|
+
report(c, options.onEvent(event));
|
|
109
|
+
return res;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
113
|
+
for (const { config, handler } of ordered) {
|
|
114
|
+
app.on(config.method, config.path, handle(config.path, async (c, event) => {
|
|
115
|
+
event.params = await parse(config.params, c.req.param(), "params");
|
|
116
|
+
event.query = await parse(config.query, c.req.query(), "query");
|
|
117
|
+
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
118
|
+
event.rawBody = await c.req.text();
|
|
119
|
+
event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
|
|
120
|
+
const result = await handler({
|
|
121
|
+
c,
|
|
122
|
+
params: event.params,
|
|
123
|
+
query: event.query,
|
|
124
|
+
headers: event.headers,
|
|
125
|
+
body: event.body,
|
|
126
|
+
rawBody: event.rawBody
|
|
127
|
+
});
|
|
128
|
+
if (result instanceof Response)
|
|
129
|
+
return result;
|
|
130
|
+
event.response = result;
|
|
131
|
+
if (config.response) {
|
|
132
|
+
const checked = await config.response["~standard"].validate(result);
|
|
133
|
+
if (checked.issues) {
|
|
134
|
+
event.validation = { target: "response", issues: checked.issues };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (result === undefined)
|
|
138
|
+
return c.body(null, 204);
|
|
139
|
+
if (typeof result === "string") {
|
|
140
|
+
return c.text(result, 200, {
|
|
141
|
+
"content-type": "text/plain; charset=UTF-8"
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return c.json(result);
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
for (const [path, methods] of methodsByPath) {
|
|
148
|
+
app.all(path, handle(path, async (c) => {
|
|
149
|
+
c.header("Allow", methods.join(", "));
|
|
150
|
+
throw new HTTPException(405, {
|
|
151
|
+
message: `Method ${c.req.method} not allowed`
|
|
152
|
+
});
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
app.all("*", handle(undefined, async () => {
|
|
156
|
+
throw new HTTPException(404, { message: "Route not found" });
|
|
157
|
+
}));
|
|
158
|
+
return Object.assign(app, { webhooks });
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
var defineWebhook = makeDefineWebhook();
|
|
162
|
+
var createWebhooks = makeCreateWebhooks();
|
|
163
|
+
function createWebhookFactory() {
|
|
164
|
+
return {
|
|
165
|
+
defineWebhook: makeDefineWebhook(),
|
|
166
|
+
createWebhooks: makeCreateWebhooks()
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export {
|
|
170
|
+
WebhookValidationError,
|
|
171
|
+
createWebhookFactory,
|
|
172
|
+
createWebhooks,
|
|
173
|
+
defineWebhook
|
|
174
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hono-webhooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/HansKristoffer/hono-webhooks.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/HansKristoffer/hono-webhooks#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/HansKristoffer/hono-webhooks/issues"
|
|
17
|
+
},
|
|
18
|
+
"author": "Kristoffer Hansen",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"hono",
|
|
21
|
+
"webhooks",
|
|
22
|
+
"webhook",
|
|
23
|
+
"standard-schema",
|
|
24
|
+
"zod",
|
|
25
|
+
"valibot",
|
|
26
|
+
"arktype",
|
|
27
|
+
"typescript",
|
|
28
|
+
"type-safe"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"src/**/*.ts",
|
|
32
|
+
"dist",
|
|
33
|
+
"!src/**/*.test.ts"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"bin": {
|
|
37
|
+
"hono-webhooks": "./dist/cli.js"
|
|
38
|
+
},
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"bun": "./src/index.ts",
|
|
43
|
+
"import": "./dist/index.js",
|
|
44
|
+
"default": "./dist/index.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
49
|
+
"build:js": "bun build ./src/index.ts ./src/cli.ts --outdir ./dist --target node --packages external",
|
|
50
|
+
"build:types": "tsgo -p tsconfig.build.json",
|
|
51
|
+
"prepublishOnly": "bun run build",
|
|
52
|
+
"test": "bun test",
|
|
53
|
+
"lint": "bun run typecheck && bun biome check",
|
|
54
|
+
"lint:write": "bun run typecheck && bun biome check --write",
|
|
55
|
+
"typecheck": "tsgo --noEmit",
|
|
56
|
+
"prepare": "husky"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@standard-schema/spec": "^1.1.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"hono": "^4.6.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@biomejs/biome": "^2.5.14",
|
|
66
|
+
"@types/bun": "^1.4.2",
|
|
67
|
+
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
|
|
68
|
+
"hono": "^4.13.10",
|
|
69
|
+
"husky": "^9.1.7",
|
|
70
|
+
"typescript": "^7.0.2",
|
|
71
|
+
"valibot": "^1.5.0",
|
|
72
|
+
"zod": "^4.6.5"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import type { StandardJSONSchemaV1 } from '@standard-schema/spec'
|
|
6
|
+
import type { WebhooksApp } from './index'
|
|
7
|
+
|
|
8
|
+
const HELP = `Usage: hono-webhooks <module> <command> [options]
|
|
9
|
+
|
|
10
|
+
<module> exports the result of createWebhooks(), as default or named export.
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
list List the webhooks
|
|
14
|
+
schema <path> Print the JSON Schemas of the webhooks at <path>
|
|
15
|
+
test <method> <path> Send a request through the webhooks app
|
|
16
|
+
|
|
17
|
+
Options for test:
|
|
18
|
+
-b, --body <text> Request body (JSON sets content-type: application/json)
|
|
19
|
+
-q, --query <params> Query string, e.g. "a=1&b=2"
|
|
20
|
+
-H, --header <k: v> Request header, repeatable
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
hono-webhooks ./src/webhooks.ts list
|
|
24
|
+
hono-webhooks ./src/webhooks.ts schema /orders/:id
|
|
25
|
+
hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
const { values, positionals } = parseArgs({
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
options: {
|
|
31
|
+
help: { type: 'boolean', short: 'h' },
|
|
32
|
+
body: { type: 'string', short: 'b' },
|
|
33
|
+
query: { type: 'string', short: 'q' },
|
|
34
|
+
header: { type: 'string', short: 'H', multiple: true }
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const [modulePath, command, ...args] = positionals
|
|
39
|
+
|
|
40
|
+
function fail(message: string): never {
|
|
41
|
+
console.error(message)
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isWebhooksApp(value: unknown): value is WebhooksApp {
|
|
46
|
+
return (
|
|
47
|
+
typeof value === 'object' &&
|
|
48
|
+
value !== null &&
|
|
49
|
+
Array.isArray((value as WebhooksApp).webhooks) &&
|
|
50
|
+
typeof (value as WebhooksApp).request === 'function'
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
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`)
|
|
58
|
+
}
|
|
59
|
+
|
|
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
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
if (values.help || !modulePath || !command) {
|
|
71
|
+
console.log(HELP)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const app = await loadApp(modulePath)
|
|
75
|
+
|
|
76
|
+
if (command === 'list') {
|
|
77
|
+
for (const { config } of app.webhooks) {
|
|
78
|
+
const validates = (['params', 'query', 'headers', 'body'] as const)
|
|
79
|
+
.filter((key) => config[key])
|
|
80
|
+
.join(', ')
|
|
81
|
+
console.log(
|
|
82
|
+
`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
|
|
83
|
+
)
|
|
84
|
+
if (validates) console.log(` validates ${validates}`)
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (command === 'schema') {
|
|
90
|
+
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) {
|
|
94
|
+
console.log(`${config.method} ${config.path}`)
|
|
95
|
+
for (const key of [
|
|
96
|
+
'params',
|
|
97
|
+
'query',
|
|
98
|
+
'headers',
|
|
99
|
+
'body',
|
|
100
|
+
'response'
|
|
101
|
+
] as const) {
|
|
102
|
+
if (!config[key]) continue
|
|
103
|
+
const json = jsonSchemaOf(config[key])
|
|
104
|
+
console.log(
|
|
105
|
+
` ${key}: ${json ? JSON.stringify(json, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (command === 'test') {
|
|
113
|
+
const [method, path] = args
|
|
114
|
+
if (!method || !path) fail('Usage: test <method> <path>')
|
|
115
|
+
const headers = new Headers()
|
|
116
|
+
for (const header of values.header ?? []) {
|
|
117
|
+
const at = header.indexOf(':')
|
|
118
|
+
if (at < 1) fail(`Header must look like "name: value", got "${header}"`)
|
|
119
|
+
headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
|
|
120
|
+
}
|
|
121
|
+
if (values.body && !headers.has('content-type')) {
|
|
122
|
+
try {
|
|
123
|
+
JSON.parse(values.body)
|
|
124
|
+
headers.set('content-type', 'application/json')
|
|
125
|
+
} catch {
|
|
126
|
+
headers.set('content-type', 'text/plain')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const url = `${path.startsWith('/') ? path : `/${path}`}${values.query ? `?${values.query}` : ''}`
|
|
131
|
+
const start = performance.now()
|
|
132
|
+
const res = await app.request(url, {
|
|
133
|
+
method: method.toUpperCase(),
|
|
134
|
+
headers,
|
|
135
|
+
body: values.body
|
|
136
|
+
})
|
|
137
|
+
const text = await res.text()
|
|
138
|
+
let body = text
|
|
139
|
+
try {
|
|
140
|
+
body = JSON.stringify(JSON.parse(text), null, 2)
|
|
141
|
+
} catch {}
|
|
142
|
+
console.log(`${res.status} (${(performance.now() - start).toFixed(1)}ms)`)
|
|
143
|
+
if (body) console.log(body)
|
|
144
|
+
if (!res.ok) process.exitCode = 1
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fail(`Unknown command: ${command}\n\n${HELP}`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await main()
|