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/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();
@@ -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()