hono-webhooks 0.4.0 → 0.5.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 +30 -5
- package/dist/cli-commands.d.ts +1 -0
- package/dist/cli.js +5674 -233
- package/dist/otel.d.ts +51 -2
- package/dist/otel.js +47 -9
- package/package.json +4 -2
- package/src/cli-commands.ts +270 -0
- package/src/cli.ts +4 -253
- package/src/otel.ts +90 -9
package/dist/otel.d.ts
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
|
-
import { type Meter, type Tracer } from '@opentelemetry/api';
|
|
1
|
+
import { type Meter, type Span, type Tracer } from '@opentelemetry/api';
|
|
2
2
|
import type { Env } from 'hono';
|
|
3
3
|
import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
|
|
4
4
|
/** The `route` label for 404s, which match no webhook. */
|
|
5
5
|
export declare const UNMATCHED_ROUTE = "__unmatched__";
|
|
6
|
+
/** A metric `otelWebhooks` records, described for a metric catalog. */
|
|
7
|
+
export interface WebhookMetricDefinition {
|
|
8
|
+
name: string;
|
|
9
|
+
kind: 'counter' | 'histogram';
|
|
10
|
+
unit?: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** Attribute key to its closed set of values, or `'string'` when open. */
|
|
13
|
+
attributes: Record<string, readonly string[] | 'string'>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The metrics `otelWebhooks` records, as data: list them in a metric
|
|
17
|
+
* catalog without creating a second recorder.
|
|
18
|
+
*/
|
|
19
|
+
export declare const webhookMetrics: readonly [{
|
|
20
|
+
readonly name: 'webhook_request_total';
|
|
21
|
+
readonly kind: 'counter';
|
|
22
|
+
readonly description: 'HTTP webhook requests by route template and status';
|
|
23
|
+
readonly attributes: {
|
|
24
|
+
readonly route: 'string';
|
|
25
|
+
readonly status: readonly ['success', 'failed'];
|
|
26
|
+
};
|
|
27
|
+
}, {
|
|
28
|
+
readonly name: 'webhook_request_duration_ms';
|
|
29
|
+
readonly kind: 'histogram';
|
|
30
|
+
readonly unit: 'ms';
|
|
31
|
+
readonly description: 'HTTP webhook handler duration by route template and status';
|
|
32
|
+
readonly attributes: {
|
|
33
|
+
readonly route: 'string';
|
|
34
|
+
readonly status: readonly ['success', 'failed'];
|
|
35
|
+
};
|
|
36
|
+
}];
|
|
37
|
+
/** `summarizeEvent(event)` plus what a completion log needs. */
|
|
38
|
+
export interface WebhookCall extends WebhookEventSummary {
|
|
39
|
+
/** `error` for 5xx, `warn` for 4xx, else `info`. */
|
|
40
|
+
level: 'info' | 'warn' | 'error';
|
|
41
|
+
outcome: 'success' | 'failed' | 'validation_failed';
|
|
42
|
+
}
|
|
43
|
+
/** `console` fits, as does any logger with these three methods. */
|
|
44
|
+
export type WebhookLogger = Record<WebhookCall['level'], (message: string, fields: Record<string, string | number>) => void>;
|
|
6
45
|
export interface OtelWebhooksOptions<E extends Env = any> {
|
|
7
46
|
/** @default trace.getTracer('webhooks') */
|
|
8
47
|
tracer?: Tracer;
|
|
@@ -15,7 +54,17 @@ export interface OtelWebhooksOptions<E extends Env = any> {
|
|
|
15
54
|
* still active: log it, store it. A returned promise is handled like
|
|
16
55
|
* `onEvent`'s.
|
|
17
56
|
*/
|
|
18
|
-
onWebhookCall?: (call:
|
|
57
|
+
onWebhookCall?: (call: WebhookCall, event: WebhookEvent<E>) => void | Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Logs one `webhook.completed` event per request at `call.level`, with
|
|
60
|
+
* `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
|
|
61
|
+
* and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
|
|
62
|
+
* request's span, so the logger can attach to it.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* logger: () => console
|
|
66
|
+
*/
|
|
67
|
+
logger?: (span: Span) => WebhookLogger;
|
|
19
68
|
}
|
|
20
69
|
/**
|
|
21
70
|
* `around` and `onEvent` that put each request in a server span and record
|
package/dist/otel.js
CHANGED
|
@@ -9,6 +9,25 @@ import {
|
|
|
9
9
|
summarizeEvent
|
|
10
10
|
} from "hono-webhooks";
|
|
11
11
|
var UNMATCHED_ROUTE = "__unmatched__";
|
|
12
|
+
var metricAttributes = {
|
|
13
|
+
route: "string",
|
|
14
|
+
status: ["success", "failed"]
|
|
15
|
+
};
|
|
16
|
+
var webhookMetrics = [
|
|
17
|
+
{
|
|
18
|
+
name: "webhook_request_total",
|
|
19
|
+
kind: "counter",
|
|
20
|
+
description: "HTTP webhook requests by route template and status",
|
|
21
|
+
attributes: metricAttributes
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "webhook_request_duration_ms",
|
|
25
|
+
kind: "histogram",
|
|
26
|
+
unit: "ms",
|
|
27
|
+
description: "HTTP webhook handler duration by route template and status",
|
|
28
|
+
attributes: metricAttributes
|
|
29
|
+
}
|
|
30
|
+
];
|
|
12
31
|
function otelWebhooks(options = {}) {
|
|
13
32
|
const tracer = options.tracer ?? trace.getTracer("webhooks");
|
|
14
33
|
const ignored = new Set(options.ignoreRoutes);
|
|
@@ -30,8 +49,13 @@ function otelWebhooks(options = {}) {
|
|
|
30
49
|
});
|
|
31
50
|
},
|
|
32
51
|
onEvent: (event) => {
|
|
33
|
-
const
|
|
34
|
-
const success =
|
|
52
|
+
const summary = summarizeEvent(event);
|
|
53
|
+
const success = summary.status < 400;
|
|
54
|
+
const call = {
|
|
55
|
+
...summary,
|
|
56
|
+
level: summary.status >= 500 ? "error" : success ? "info" : "warn",
|
|
57
|
+
outcome: summary.validation ? "validation_failed" : success ? "success" : "failed"
|
|
58
|
+
};
|
|
35
59
|
const span = spans.get(event.c);
|
|
36
60
|
span?.setAttributes({
|
|
37
61
|
"http.status_code": call.status,
|
|
@@ -47,7 +71,8 @@ function otelWebhooks(options = {}) {
|
|
|
47
71
|
span?.setStatus({
|
|
48
72
|
code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
|
|
49
73
|
});
|
|
50
|
-
|
|
74
|
+
const isIgnored = call.route !== null && ignored.has(call.route);
|
|
75
|
+
if (!isIgnored) {
|
|
51
76
|
instruments ??= createInstruments(options.meter ?? metrics.getMeter("webhooks"));
|
|
52
77
|
const attributes = {
|
|
53
78
|
route: call.route ?? UNMATCHED_ROUTE,
|
|
@@ -56,22 +81,35 @@ function otelWebhooks(options = {}) {
|
|
|
56
81
|
instruments.total.add(1, attributes);
|
|
57
82
|
instruments.duration.record(event.durationMs, attributes);
|
|
58
83
|
}
|
|
84
|
+
if (span && options.logger && call.route !== null && !isIgnored) {
|
|
85
|
+
options.logger(span)[call.level]("webhook.completed", {
|
|
86
|
+
event: "webhook.completed",
|
|
87
|
+
route: call.route,
|
|
88
|
+
method: call.method,
|
|
89
|
+
status_code: call.status,
|
|
90
|
+
duration_ms: call.durationMs,
|
|
91
|
+
status: call.outcome,
|
|
92
|
+
...call.error && { error_type: call.error.name }
|
|
93
|
+
});
|
|
94
|
+
}
|
|
59
95
|
return options.onWebhookCall?.(call, event);
|
|
60
96
|
}
|
|
61
97
|
};
|
|
62
98
|
}
|
|
63
99
|
function createInstruments(meter) {
|
|
100
|
+
const [total, duration] = webhookMetrics;
|
|
64
101
|
return {
|
|
65
|
-
total: meter.createCounter(
|
|
66
|
-
description:
|
|
102
|
+
total: meter.createCounter(total.name, {
|
|
103
|
+
description: total.description
|
|
67
104
|
}),
|
|
68
|
-
duration: meter.createHistogram(
|
|
69
|
-
unit:
|
|
70
|
-
description:
|
|
105
|
+
duration: meter.createHistogram(duration.name, {
|
|
106
|
+
unit: duration.unit,
|
|
107
|
+
description: duration.description
|
|
71
108
|
})
|
|
72
109
|
};
|
|
73
110
|
}
|
|
74
111
|
export {
|
|
75
112
|
UNMATCHED_ROUTE,
|
|
76
|
-
otelWebhooks
|
|
113
|
+
otelWebhooks,
|
|
114
|
+
webhookMetrics
|
|
77
115
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hono-webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
62
|
-
"build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/otel.ts
|
|
62
|
+
"build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/otel.ts --outdir ./dist --target node --packages external && bun run build:cli",
|
|
63
|
+
"build:cli": "bun build ./src/cli.ts --outdir ./dist --target node --external hono --external @standard-schema/spec",
|
|
63
64
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
64
65
|
"prepublishOnly": "bun run build",
|
|
65
66
|
"test": "bun test",
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"@opentelemetry/sdk-trace-base": "^2.12.0",
|
|
88
89
|
"@types/bun": "^1.4.2",
|
|
89
90
|
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
|
|
91
|
+
"hanzio": "^3.1.0",
|
|
90
92
|
"hono": "^4.13.10",
|
|
91
93
|
"husky": "^9.1.7",
|
|
92
94
|
"typescript": "^7.0.2",
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { CliError, type CliWriter, defineCli, defineCommand } from 'hanzio/cli'
|
|
6
|
+
import { z } from 'zod'
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A file path relative to the cwd, or a package name. */
|
|
29
|
+
const importModule = (specifier: string) =>
|
|
30
|
+
import(
|
|
31
|
+
existsSync(resolve(specifier))
|
|
32
|
+
? pathToFileURL(resolve(specifier)).href
|
|
33
|
+
: specifier
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
function isWebhooksApp(value: unknown): value is WebhooksApp {
|
|
37
|
+
return (
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
Array.isArray((value as WebhooksApp).webhooks) &&
|
|
41
|
+
typeof (value as WebhooksApp).request === 'function'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isWebhookArray(value: unknown): value is Webhook[] {
|
|
46
|
+
return (
|
|
47
|
+
Array.isArray(value) &&
|
|
48
|
+
value.length > 0 &&
|
|
49
|
+
value.every(
|
|
50
|
+
(item) =>
|
|
51
|
+
typeof item?.config?.path === 'string' &&
|
|
52
|
+
typeof item?.handler === 'function'
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadApp(path: string): Promise<WebhooksApp> {
|
|
58
|
+
const mod = await importModule(path)
|
|
59
|
+
const candidates = [mod.default, ...Object.values(mod)]
|
|
60
|
+
const app = candidates.find(isWebhooksApp)
|
|
61
|
+
if (app) return app
|
|
62
|
+
const webhooks = candidates.find(isWebhookArray)
|
|
63
|
+
if (webhooks) return createWebhooks(webhooks)
|
|
64
|
+
throw new CliError(
|
|
65
|
+
`${path} exports neither a createWebhooks() app nor an array of webhooks`
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Errors Node gives for TypeScript it can't load but Bun can. */
|
|
70
|
+
const BUN_ONLY_ERRORS = new Set([
|
|
71
|
+
'ERR_MODULE_NOT_FOUND',
|
|
72
|
+
'ERR_UNKNOWN_FILE_EXTENSION',
|
|
73
|
+
'ERR_UNSUPPORTED_DIR_IMPORT',
|
|
74
|
+
'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX',
|
|
75
|
+
'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Under Node, a Bun project's TypeScript (extensionless imports, for one)
|
|
80
|
+
* fails to load. Rerun the same command under Bun when it is installed and
|
|
81
|
+
* return its exit code.
|
|
82
|
+
*/
|
|
83
|
+
function rerunUnderBun(error: unknown, stderr: CliWriter): number {
|
|
84
|
+
const code = (error as { code?: string }).code
|
|
85
|
+
if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code)) throw error
|
|
86
|
+
stderr.write(`Node can't load the module (${code}), trying Bun\n`)
|
|
87
|
+
const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
|
|
88
|
+
if (run.error) {
|
|
89
|
+
throw new CliError(
|
|
90
|
+
'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return run.status ?? 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// One command with <module> <command> positionals: hanzio/cli reads command
|
|
97
|
+
// names first, so it can't put the module before them.
|
|
98
|
+
export const cli = defineCli({
|
|
99
|
+
name: 'hono-webhooks',
|
|
100
|
+
commands: defineCommand({
|
|
101
|
+
description: `<module> exports a createWebhooks() app or an array of webhooks, as default
|
|
102
|
+
or named export.
|
|
103
|
+
|
|
104
|
+
Commands:
|
|
105
|
+
list List the webhooks
|
|
106
|
+
schema <path> Print the JSON Schemas of the webhooks at <path>
|
|
107
|
+
test <method> <path> Send a request through the webhooks app
|
|
108
|
+
|
|
109
|
+
Examples:
|
|
110
|
+
hono-webhooks ./src/webhooks.ts list
|
|
111
|
+
hono-webhooks ./src/webhooks.ts schema /orders/:id
|
|
112
|
+
hono-webhooks ./src/webhooks.ts test POST /orders/42 -b '{"total":10}'
|
|
113
|
+
hono-webhooks ./src/webhooks.ts test POST /shopify -b '{"id":1}' \\
|
|
114
|
+
-r ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET`,
|
|
115
|
+
input: z.object({
|
|
116
|
+
module: z
|
|
117
|
+
.string()
|
|
118
|
+
.optional()
|
|
119
|
+
.meta({ positional: true, description: 'Module with the webhooks' }),
|
|
120
|
+
command: z
|
|
121
|
+
.enum(['list', 'schema', 'test'])
|
|
122
|
+
.optional()
|
|
123
|
+
.meta({ positional: true, description: 'list, schema or test' }),
|
|
124
|
+
args: z.array(z.string()).meta({
|
|
125
|
+
positional: true,
|
|
126
|
+
description: 'schema: <path>, test: <method> <path>'
|
|
127
|
+
}),
|
|
128
|
+
preload: z.array(z.string()).meta({
|
|
129
|
+
alias: 'r',
|
|
130
|
+
description: 'Import a module first (e.g. to load secrets), repeatable'
|
|
131
|
+
}),
|
|
132
|
+
body: z.string().optional().meta({
|
|
133
|
+
alias: 'b',
|
|
134
|
+
description:
|
|
135
|
+
'test: request body (JSON sets content-type: application/json)'
|
|
136
|
+
}),
|
|
137
|
+
query: z.string().optional().meta({
|
|
138
|
+
alias: 'q',
|
|
139
|
+
description: 'test: query string, e.g. "a=1&b=2"'
|
|
140
|
+
}),
|
|
141
|
+
header: z.array(z.string()).meta({
|
|
142
|
+
alias: 'H',
|
|
143
|
+
description: 'test: request header "name: value", repeatable'
|
|
144
|
+
}),
|
|
145
|
+
sign: z
|
|
146
|
+
.string()
|
|
147
|
+
.optional()
|
|
148
|
+
.meta({
|
|
149
|
+
alias: 's',
|
|
150
|
+
description: `test: sign the body with ${Object.keys(schemes).join(', ')}`
|
|
151
|
+
}),
|
|
152
|
+
secretEnv: z.string().optional().meta({
|
|
153
|
+
alias: 'e',
|
|
154
|
+
description: 'test: environment variable holding the signing secret'
|
|
155
|
+
})
|
|
156
|
+
}),
|
|
157
|
+
async run(input, ctx) {
|
|
158
|
+
const print = (line: string) => ctx.stdout.write(`${line}\n`)
|
|
159
|
+
if (!input.module || !input.command) {
|
|
160
|
+
ctx.exitCode = await cli.run(['--help'], ctx)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
let app: WebhooksApp
|
|
164
|
+
try {
|
|
165
|
+
for (const preload of input.preload) await importModule(preload)
|
|
166
|
+
app = await loadApp(input.module)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
ctx.exitCode = rerunUnderBun(error, ctx.stderr)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (input.command === 'list') {
|
|
173
|
+
for (const { config } of app.webhooks) {
|
|
174
|
+
const validates = [
|
|
175
|
+
...(['params', 'query', 'headers', 'body'] as const).filter(
|
|
176
|
+
(key) => config[key]
|
|
177
|
+
),
|
|
178
|
+
...(config.verify ? ['signature'] : [])
|
|
179
|
+
].join(', ')
|
|
180
|
+
print(
|
|
181
|
+
`${config.method.padEnd(7)} ${config.path}${config.description ? ` ${config.description}` : ''}`
|
|
182
|
+
)
|
|
183
|
+
if (validates) print(` validates ${validates}`)
|
|
184
|
+
}
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (input.command === 'schema') {
|
|
189
|
+
const [path] = input.args
|
|
190
|
+
const descriptions = describeWebhooks(app)
|
|
191
|
+
const indexes = app.webhooks.flatMap((w, i) =>
|
|
192
|
+
w.config.path === path ? [i] : []
|
|
193
|
+
)
|
|
194
|
+
if (!indexes.length) throw new CliError(`No webhook at ${path}`)
|
|
195
|
+
for (const i of indexes) {
|
|
196
|
+
const { config } = app.webhooks[i]!
|
|
197
|
+
print(`${config.method} ${config.path}`)
|
|
198
|
+
for (const part of [
|
|
199
|
+
'params',
|
|
200
|
+
'query',
|
|
201
|
+
'headers',
|
|
202
|
+
'body',
|
|
203
|
+
'response'
|
|
204
|
+
] as const) {
|
|
205
|
+
if (!config[part]) continue
|
|
206
|
+
const schema = descriptions[i]!.jsonSchema[part]
|
|
207
|
+
print(
|
|
208
|
+
` ${part}: ${schema ? JSON.stringify(schema, null, 2).replaceAll('\n', '\n ') : '(no JSON Schema support)'}`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const [method, path] = input.args
|
|
216
|
+
if (!method || !path) throw new CliError('Usage: test <method> <path>')
|
|
217
|
+
const headers = new Headers()
|
|
218
|
+
for (const header of input.header) {
|
|
219
|
+
const at = header.indexOf(':')
|
|
220
|
+
if (at < 1) {
|
|
221
|
+
throw new CliError(
|
|
222
|
+
`Header must look like "name: value", got "${header}"`
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
headers.set(header.slice(0, at).trim(), header.slice(at + 1).trim())
|
|
226
|
+
}
|
|
227
|
+
if (input.body && !headers.has('content-type')) {
|
|
228
|
+
try {
|
|
229
|
+
JSON.parse(input.body)
|
|
230
|
+
headers.set('content-type', 'application/json')
|
|
231
|
+
} catch {
|
|
232
|
+
headers.set('content-type', 'text/plain')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (input.sign) {
|
|
236
|
+
const scheme = schemes[input.sign]
|
|
237
|
+
if (!scheme) {
|
|
238
|
+
throw new CliError(
|
|
239
|
+
`Unknown scheme "${input.sign}", use one of: ${Object.keys(schemes).join(', ')}`
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
const name = input.secretEnv
|
|
243
|
+
if (!name) throw new CliError('--sign needs --secret-env <name>')
|
|
244
|
+
const secret = ctx.env[name]
|
|
245
|
+
if (secret === undefined) throw new CliError(`${name} is not set`)
|
|
246
|
+
for (const [key, value] of Object.entries(
|
|
247
|
+
await scheme.sign(input.body ?? '', secret)
|
|
248
|
+
)) {
|
|
249
|
+
headers.set(key, value)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const url = `${path.startsWith('/') ? path : `/${path}`}${input.query ? `?${input.query}` : ''}`
|
|
254
|
+
const start = performance.now()
|
|
255
|
+
const res = await app.request(url, {
|
|
256
|
+
method: method.toUpperCase(),
|
|
257
|
+
headers,
|
|
258
|
+
body: input.body
|
|
259
|
+
})
|
|
260
|
+
const text = await res.text()
|
|
261
|
+
let body = text
|
|
262
|
+
try {
|
|
263
|
+
body = JSON.stringify(JSON.parse(text), null, 2)
|
|
264
|
+
} catch {}
|
|
265
|
+
print(`${res.status} (${(performance.now() - start).toFixed(1)}ms)`)
|
|
266
|
+
if (body) print(body)
|
|
267
|
+
if (!res.ok) ctx.exitCode = 1
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
})
|