honk-me 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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/cjs/client.d.ts +58 -0
- package/dist/cjs/client.js +334 -0
- package/dist/cjs/errors.d.ts +84 -0
- package/dist/cjs/errors.js +108 -0
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/types.d.ts +138 -0
- package/dist/cjs/types.js +53 -0
- package/dist/cjs/uuid.d.ts +5 -0
- package/dist/cjs/uuid.js +40 -0
- package/dist/cjs/validate.d.ts +28 -0
- package/dist/cjs/validate.js +272 -0
- package/dist/cjs/version.d.ts +1 -0
- package/dist/cjs/version.js +6 -0
- package/dist/esm/client.d.ts +58 -0
- package/dist/esm/client.js +329 -0
- package/dist/esm/errors.d.ts +84 -0
- package/dist/esm/errors.js +97 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/types.d.ts +138 -0
- package/dist/esm/types.js +49 -0
- package/dist/esm/uuid.d.ts +5 -0
- package/dist/esm/uuid.js +37 -0
- package/dist/esm/validate.d.ts +28 -0
- package/dist/esm/validate.js +265 -0
- package/dist/esm/version.d.ts +1 -0
- package/dist/esm/version.js +3 -0
- package/package.json +68 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.byteLength = exports.LIMITS = void 0;
|
|
4
|
+
exports.checkIdempotencyKey = checkIdempotencyKey;
|
|
5
|
+
exports.buildBody = buildBody;
|
|
6
|
+
exports.validUrl = validUrl;
|
|
7
|
+
const errors_js_1 = require("./errors.js");
|
|
8
|
+
const types_js_1 = require("./types.js");
|
|
9
|
+
/** Limits from contracts/openapi.yaml (and the server's validator). */
|
|
10
|
+
exports.LIMITS = {
|
|
11
|
+
bodyBytes: 16 * 1024,
|
|
12
|
+
messageBytes: 8192,
|
|
13
|
+
title: 160,
|
|
14
|
+
source: 64,
|
|
15
|
+
environment: 32,
|
|
16
|
+
channel: 64,
|
|
17
|
+
groupKey: 128,
|
|
18
|
+
urlBytes: 2048,
|
|
19
|
+
metadataKeys: 16,
|
|
20
|
+
metadataValue: 512,
|
|
21
|
+
ttlMin: 60,
|
|
22
|
+
ttlMax: 86400,
|
|
23
|
+
idempotencyKey: 128,
|
|
24
|
+
};
|
|
25
|
+
// camelCase SDK field → snake_case wire field, in a stable order.
|
|
26
|
+
const FIELDS = {
|
|
27
|
+
title: 'title',
|
|
28
|
+
message: 'message',
|
|
29
|
+
severity: 'severity',
|
|
30
|
+
priority: 'priority',
|
|
31
|
+
category: 'category',
|
|
32
|
+
source: 'source',
|
|
33
|
+
environment: 'environment',
|
|
34
|
+
channel: 'channel',
|
|
35
|
+
groupKey: 'group_key',
|
|
36
|
+
eventType: 'event_type',
|
|
37
|
+
occurredAt: 'occurred_at',
|
|
38
|
+
url: 'url',
|
|
39
|
+
imageUrl: 'image_url',
|
|
40
|
+
metadata: 'metadata',
|
|
41
|
+
ttlSeconds: 'ttl_seconds',
|
|
42
|
+
sourceSequence: 'source_sequence',
|
|
43
|
+
};
|
|
44
|
+
const WIRE_TO_FIELD = Object.fromEntries(Object.entries(FIELDS).map(([k, v]) => [v, k]));
|
|
45
|
+
// Go's unicode.IsControl (C0, DEL, C1) plus the Unicode line/paragraph separators, as on the server.
|
|
46
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
47
|
+
const CONTROL_EXCEPT_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
48
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
49
|
+
const METADATA_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
50
|
+
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,128}$/;
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
const byteLength = (s) => encoder.encode(s).length;
|
|
53
|
+
exports.byteLength = byteLength;
|
|
54
|
+
const codePoints = (s) => {
|
|
55
|
+
let n = 0;
|
|
56
|
+
for (const _ of s)
|
|
57
|
+
n++;
|
|
58
|
+
return n;
|
|
59
|
+
};
|
|
60
|
+
const isBlank = (v) => v === undefined || v === null || v === '';
|
|
61
|
+
/** Checks an Idempotency-Key: 1–128 printable ASCII characters (0x21–0x7E). */
|
|
62
|
+
function checkIdempotencyKey(key) {
|
|
63
|
+
if (typeof key !== 'string' || !IDEMPOTENCY_KEY.test(key)) {
|
|
64
|
+
throw new errors_js_1.HonkValidationError('Invalid idempotency key: use 1–128 printable ASCII characters without spaces, e.g. "request-4812"', { local: true, code: 'validation_failed', fields: [{ field: 'Idempotency-Key', code: 'invalid_format', message: '1-128 printable ASCII characters' }] });
|
|
65
|
+
}
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Turns a message into the wire object of `POST /v1/messages`, applying `defaults` and (unless
|
|
70
|
+
* `validate` is false) the cheap checks the server would apply anyway. Throws HonkValidationError
|
|
71
|
+
* listing every invalid field.
|
|
72
|
+
*/
|
|
73
|
+
function buildBody(input, defaults = {}, validate = true) {
|
|
74
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
75
|
+
throw new errors_js_1.HonkValidationError('Invalid Honk message: expected an object like { title, message }', {
|
|
76
|
+
local: true,
|
|
77
|
+
code: 'validation_failed',
|
|
78
|
+
fields: [{ field: 'body', code: 'invalid_format', message: 'must be an object' }],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const errors = [];
|
|
82
|
+
const add = (field, code, message) => errors.push({ field, code, message });
|
|
83
|
+
const msg = input;
|
|
84
|
+
for (const key of Object.keys(msg)) {
|
|
85
|
+
if (!(key in FIELDS)) {
|
|
86
|
+
const hint = WIRE_TO_FIELD[key] ? ` (use ${WIRE_TO_FIELD[key]})` : '';
|
|
87
|
+
add(key, 'not_allowed', `unknown field${hint}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const value = (name) => {
|
|
91
|
+
const v = msg[name];
|
|
92
|
+
if (!isBlank(v))
|
|
93
|
+
return v;
|
|
94
|
+
if (name === 'source' || name === 'environment' || name === 'channel') {
|
|
95
|
+
const d = defaults[name];
|
|
96
|
+
return isBlank(d) ? undefined : d;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
const body = {};
|
|
101
|
+
for (const name of Object.keys(FIELDS)) {
|
|
102
|
+
const v = value(name);
|
|
103
|
+
if (v === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
const wire = FIELDS[name];
|
|
106
|
+
if (name === 'occurredAt') {
|
|
107
|
+
if (v instanceof Date) {
|
|
108
|
+
if (Number.isNaN(v.getTime()))
|
|
109
|
+
add(wire, 'invalid_format', 'must be a valid date');
|
|
110
|
+
else
|
|
111
|
+
body[wire] = v.toISOString();
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (validate && (typeof v !== 'string' || !RFC3339.test(v.trim()))) {
|
|
115
|
+
add(wire, 'invalid_format', 'must be a Date or an RFC 3339 timestamp like 2026-10-01T21:10:00Z');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Horn aliases (and any case) become the canonical value, even with validate: false, so
|
|
120
|
+
// the idempotency payload is canonical and older servers understand it.
|
|
121
|
+
body[wire] = name === 'severity' && typeof v === 'string' ? ((0, types_js_1.normalizeSeverity)(v) ?? v) : v;
|
|
122
|
+
}
|
|
123
|
+
if (validate) {
|
|
124
|
+
checkMessage(body, add);
|
|
125
|
+
}
|
|
126
|
+
else if (msg.message === undefined || msg.message === null) {
|
|
127
|
+
add('message', 'required', 'message is required');
|
|
128
|
+
}
|
|
129
|
+
if (errors.length === 0) {
|
|
130
|
+
const size = (0, exports.byteLength)(JSON.stringify(body));
|
|
131
|
+
if (size > exports.LIMITS.bodyBytes)
|
|
132
|
+
add('body', 'too_long', `the JSON body is ${size} bytes; Honk accepts at most 16 KiB`);
|
|
133
|
+
}
|
|
134
|
+
if (errors.length > 0) {
|
|
135
|
+
const summary = errors.map((e) => `${e.field} ${e.message}`).join('; ');
|
|
136
|
+
throw new errors_js_1.HonkValidationError(`Invalid Honk message: ${summary}`, { local: true, code: 'validation_failed', fields: errors });
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
function checkMessage(b, add) {
|
|
141
|
+
const m = b.message;
|
|
142
|
+
if (m === undefined)
|
|
143
|
+
add('message', 'required', 'message is required');
|
|
144
|
+
else if (typeof m !== 'string')
|
|
145
|
+
add('message', 'invalid_format', 'must be a string');
|
|
146
|
+
else if ((0, exports.byteLength)(m) > exports.LIMITS.messageBytes)
|
|
147
|
+
add('message', 'too_long', `must be at most ${exports.LIMITS.messageBytes} bytes of UTF-8 (got ${(0, exports.byteLength)(m)})`);
|
|
148
|
+
else if (m.trim() === '')
|
|
149
|
+
add('message', 'too_short', 'must not be blank');
|
|
150
|
+
else if (CONTROL_EXCEPT_BREAKS.test(m))
|
|
151
|
+
add('message', 'invalid_format', 'must not contain control characters other than line breaks and tabs');
|
|
152
|
+
shortText(b, 'title', exports.LIMITS.title, add);
|
|
153
|
+
shortText(b, 'source', exports.LIMITS.source, add);
|
|
154
|
+
shortText(b, 'environment', exports.LIMITS.environment, add);
|
|
155
|
+
shortText(b, 'channel', exports.LIMITS.channel, add);
|
|
156
|
+
shortText(b, 'group_key', exports.LIMITS.groupKey, add);
|
|
157
|
+
if (b.severity !== undefined && (typeof b.severity !== 'string' || !types_js_1.SEVERITIES.includes(b.severity))) {
|
|
158
|
+
const aliases = Object.entries(types_js_1.SEVERITY_ALIASES).map(([alias, canonical]) => `${alias} (${canonical})`);
|
|
159
|
+
add('severity', 'invalid_enum', `must be one of ${aliases.join(', ')}`);
|
|
160
|
+
}
|
|
161
|
+
oneOf(b, 'priority', types_js_1.PRIORITIES, add);
|
|
162
|
+
oneOf(b, 'event_type', types_js_1.EVENT_TYPES, add);
|
|
163
|
+
oneOf(b, 'category', types_js_1.CATEGORIES, add);
|
|
164
|
+
const seq = b.source_sequence;
|
|
165
|
+
if (seq !== undefined && (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
|
|
166
|
+
add('source_sequence', 'out_of_range', 'must be an integer between 0 and 2^53-1');
|
|
167
|
+
}
|
|
168
|
+
if (b.group_key === undefined) {
|
|
169
|
+
if (b.event_type === 'recovery')
|
|
170
|
+
add('group_key', 'requires_group_key', 'recovery events require group_key');
|
|
171
|
+
if (seq !== undefined)
|
|
172
|
+
add('source_sequence', 'requires_group_key', 'source_sequence requires group_key');
|
|
173
|
+
}
|
|
174
|
+
if (b.url !== undefined && !validUrl(b.url, false)) {
|
|
175
|
+
add('url', 'invalid_format', 'must be an https URL without credentials, at most 2048 bytes');
|
|
176
|
+
}
|
|
177
|
+
if (b.image_url !== undefined && !validUrl(b.image_url, true)) {
|
|
178
|
+
add('image_url', 'invalid_format', 'must be an https URL without credentials or fragment, at most 2048 bytes');
|
|
179
|
+
}
|
|
180
|
+
const md = b.metadata;
|
|
181
|
+
if (md !== undefined) {
|
|
182
|
+
if (typeof md !== 'object' || md === null || Array.isArray(md)) {
|
|
183
|
+
add('metadata', 'invalid_format', 'must be an object of strings, numbers and booleans');
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const entries = Object.entries(md);
|
|
187
|
+
if (entries.length > exports.LIMITS.metadataKeys)
|
|
188
|
+
add('metadata', 'too_long', `at most ${exports.LIMITS.metadataKeys} keys`);
|
|
189
|
+
for (const [k, v] of entries) {
|
|
190
|
+
const field = `metadata.${k}`;
|
|
191
|
+
if (!METADATA_KEY.test(k))
|
|
192
|
+
add(field, 'invalid_format', 'keys must match [A-Za-z0-9_.-]{1,64}');
|
|
193
|
+
else if (typeof v === 'string') {
|
|
194
|
+
if (codePoints(v) > exports.LIMITS.metadataValue || CONTROL_EXCEPT_BREAKS.test(v)) {
|
|
195
|
+
add(field, 'invalid_format', `strings must be at most ${exports.LIMITS.metadataValue} characters without control characters`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (typeof v === 'number') {
|
|
199
|
+
if (!Number.isFinite(v))
|
|
200
|
+
add(field, 'invalid_format', 'numbers must be finite');
|
|
201
|
+
}
|
|
202
|
+
else if (typeof v !== 'boolean') {
|
|
203
|
+
add(field, 'invalid_format', 'values must be strings, numbers or booleans');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const ttl = b.ttl_seconds;
|
|
209
|
+
if (ttl !== undefined && (typeof ttl !== 'number' || !Number.isInteger(ttl) || ttl < exports.LIMITS.ttlMin || ttl > exports.LIMITS.ttlMax)) {
|
|
210
|
+
add('ttl_seconds', 'out_of_range', `must be an integer between ${exports.LIMITS.ttlMin} and ${exports.LIMITS.ttlMax}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function shortText(b, field, max, add) {
|
|
214
|
+
const v = b[field];
|
|
215
|
+
if (v === undefined)
|
|
216
|
+
return;
|
|
217
|
+
if (typeof v !== 'string')
|
|
218
|
+
return add(field, 'invalid_format', 'must be a string');
|
|
219
|
+
const s = v.trim();
|
|
220
|
+
if (s === '')
|
|
221
|
+
add(field, 'too_short', 'must not be empty');
|
|
222
|
+
else if (codePoints(s) > max)
|
|
223
|
+
add(field, 'too_long', `must be at most ${max} characters`);
|
|
224
|
+
else if (CONTROL.test(s))
|
|
225
|
+
add(field, 'invalid_format', 'must not contain control characters or line breaks');
|
|
226
|
+
}
|
|
227
|
+
function oneOf(b, field, allowed, add) {
|
|
228
|
+
const v = b[field];
|
|
229
|
+
if (v !== undefined && (typeof v !== 'string' || !allowed.includes(v))) {
|
|
230
|
+
add(field, 'invalid_enum', `must be one of ${allowed.join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Syntactic check matching the server: https, a host, no credentials, no spaces/backslashes, ≤ 2048 bytes. */
|
|
234
|
+
function validUrl(raw, image) {
|
|
235
|
+
if (typeof raw !== 'string')
|
|
236
|
+
return false;
|
|
237
|
+
const s = raw.trim();
|
|
238
|
+
if (s === '' || (0, exports.byteLength)(s) > exports.LIMITS.urlBytes || CONTROL.test(s) || /[ \\]/.test(s))
|
|
239
|
+
return false;
|
|
240
|
+
if (!/^https:\/\//i.test(s))
|
|
241
|
+
return false;
|
|
242
|
+
if (image && s.includes('#'))
|
|
243
|
+
return false;
|
|
244
|
+
const rest = s.slice('https://'.length);
|
|
245
|
+
const authority = rest.split(/[/?#]/, 1)[0] ?? '';
|
|
246
|
+
if (authority === '' || authority.includes('@'))
|
|
247
|
+
return false;
|
|
248
|
+
let host = authority;
|
|
249
|
+
let port = '';
|
|
250
|
+
if (authority.startsWith('[')) {
|
|
251
|
+
const end = authority.indexOf(']');
|
|
252
|
+
if (end < 0)
|
|
253
|
+
return false;
|
|
254
|
+
host = authority.slice(0, end + 1);
|
|
255
|
+
const after = authority.slice(end + 1);
|
|
256
|
+
if (after !== '' && !after.startsWith(':'))
|
|
257
|
+
return false;
|
|
258
|
+
port = after.slice(1);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const i = authority.lastIndexOf(':');
|
|
262
|
+
if (i >= 0) {
|
|
263
|
+
host = authority.slice(0, i);
|
|
264
|
+
port = authority.slice(i + 1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (host === '' || host === '[]')
|
|
268
|
+
return false;
|
|
269
|
+
if (port !== '' && (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535))
|
|
270
|
+
return false;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.1.0";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Defaults, HelperOptions, HonkOptions, Message, SendOptions, SendResult } from './types.js';
|
|
2
|
+
import { VERSION } from './version.js';
|
|
3
|
+
export { VERSION };
|
|
4
|
+
/** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
|
|
5
|
+
export declare function parseRetryAfter(value: string | null, now?: number): number | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Client for `POST /v1/messages`. Create one per process and reuse it (connections are kept alive).
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const honk = new Honk({ url: process.env.HONK_URL!, key: process.env.HONK_KEY! });
|
|
11
|
+
* await honk.send({ title: 'Backup finished', message: 'nightly pg_dump took 42 s', severity: 'success' });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare class Honk {
|
|
15
|
+
#private;
|
|
16
|
+
readonly url: string;
|
|
17
|
+
readonly timeoutMs: number;
|
|
18
|
+
readonly retries: number;
|
|
19
|
+
readonly deadlineMs: number;
|
|
20
|
+
readonly defaults: Readonly<Defaults>;
|
|
21
|
+
readonly validate: boolean;
|
|
22
|
+
constructor(options: HonkOptions);
|
|
23
|
+
/**
|
|
24
|
+
* Reads `HONK_URL`, `HONK_KEY` and optionally `HONK_SOURCE`, `HONK_ENVIRONMENT`, `HONK_CHANNEL`
|
|
25
|
+
* from the environment. `options` override them.
|
|
26
|
+
*/
|
|
27
|
+
static fromEnv(options?: Partial<HonkOptions>): Honk;
|
|
28
|
+
/**
|
|
29
|
+
* Sends one event. Resolves once Honk has durably stored it (`202`), which does not mean a push
|
|
30
|
+
* was delivered. Retries network errors, 429 and 5xx with the same Idempotency-Key until
|
|
31
|
+
* `retries` or `deadlineMs` runs out, then throws a {@link HonkError}.
|
|
32
|
+
*/
|
|
33
|
+
send(message: Message, options?: SendOptions): Promise<SendResult>;
|
|
34
|
+
/** A `problem` for `groupKey` (opens or continues an incident). Severity defaults to `long` (error). */
|
|
35
|
+
problem(groupKey: string, title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
36
|
+
/** A `recovery` for `groupKey` (closes its open incident). Severity defaults to `beep` (success). */
|
|
37
|
+
recovery(groupKey: string, title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
38
|
+
/** A light honk (severity info). */
|
|
39
|
+
light(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
40
|
+
/** A beep-beep (severity success). */
|
|
41
|
+
beep(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
42
|
+
/** A loud honk (severity warning). */
|
|
43
|
+
loud(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
44
|
+
/** A long honk (severity error; pushes at least as high priority). */
|
|
45
|
+
long(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
46
|
+
/** A blast (severity critical; pushes at least as high priority). */
|
|
47
|
+
blast(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
48
|
+
/** Synonym of {@link light}. */
|
|
49
|
+
info(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
50
|
+
/** Synonym of {@link beep}. */
|
|
51
|
+
success(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
52
|
+
/** Synonym of {@link loud}. */
|
|
53
|
+
warning(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
54
|
+
/** Synonym of {@link long}. */
|
|
55
|
+
error(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
56
|
+
/** Synonym of {@link blast}. */
|
|
57
|
+
critical(title: string | null, message: string, options?: HelperOptions): Promise<SendResult>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { HonkAuthError, HonkConflictError, HonkError, HonkNetworkError, HonkQuotaError, HonkServerError, HonkTimeoutError, HonkValidationError, } from './errors.js';
|
|
2
|
+
import { uuidv7 } from './uuid.js';
|
|
3
|
+
import { buildBody, checkIdempotencyKey } from './validate.js';
|
|
4
|
+
import { VERSION } from './version.js';
|
|
5
|
+
export { VERSION };
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
7
|
+
const DEFAULT_RETRIES = 4;
|
|
8
|
+
const DEFAULT_DEADLINE_MS = 30_000;
|
|
9
|
+
const DEFAULT_BACKOFF_BASE_MS = 500;
|
|
10
|
+
const DEFAULT_BACKOFF_MAX_MS = 8_000;
|
|
11
|
+
/** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
|
|
12
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
13
|
+
if (value === null)
|
|
14
|
+
return undefined;
|
|
15
|
+
const v = value.trim();
|
|
16
|
+
if (/^\d+$/.test(v))
|
|
17
|
+
return Number(v);
|
|
18
|
+
const at = Date.parse(v);
|
|
19
|
+
if (Number.isNaN(at))
|
|
20
|
+
return undefined;
|
|
21
|
+
return Math.max(0, Math.ceil((at - now) / 1000));
|
|
22
|
+
}
|
|
23
|
+
class TimeoutSignal {
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Client for `POST /v1/messages`. Create one per process and reuse it (connections are kept alive).
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const honk = new Honk({ url: process.env.HONK_URL!, key: process.env.HONK_KEY! });
|
|
30
|
+
* await honk.send({ title: 'Backup finished', message: 'nightly pg_dump took 42 s', severity: 'success' });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class Honk {
|
|
34
|
+
url;
|
|
35
|
+
timeoutMs;
|
|
36
|
+
retries;
|
|
37
|
+
deadlineMs;
|
|
38
|
+
defaults;
|
|
39
|
+
validate;
|
|
40
|
+
#key;
|
|
41
|
+
#fetch;
|
|
42
|
+
#backoffBase;
|
|
43
|
+
#backoffMax;
|
|
44
|
+
#userAgent;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
if (options === null || typeof options !== 'object')
|
|
47
|
+
throw new TypeError('Honk: pass options like { url, key }');
|
|
48
|
+
this.url = normalizeUrl(options.url);
|
|
49
|
+
this.#key = normalizeKey(options.key);
|
|
50
|
+
this.timeoutMs = positive(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
|
|
51
|
+
this.deadlineMs = positive(options.deadlineMs, DEFAULT_DEADLINE_MS, 'deadlineMs');
|
|
52
|
+
this.retries = options.retries ?? DEFAULT_RETRIES;
|
|
53
|
+
if (!Number.isInteger(this.retries) || this.retries < 0)
|
|
54
|
+
throw new TypeError('Honk: retries must be an integer ≥ 0');
|
|
55
|
+
this.#backoffBase = positive(options.backoff?.baseMs, DEFAULT_BACKOFF_BASE_MS, 'backoff.baseMs');
|
|
56
|
+
this.#backoffMax = positive(options.backoff?.maxMs, DEFAULT_BACKOFF_MAX_MS, 'backoff.maxMs');
|
|
57
|
+
this.defaults = Object.freeze({ ...(options.defaults ?? {}) });
|
|
58
|
+
this.validate = options.validate ?? true;
|
|
59
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
60
|
+
if (typeof f !== 'function')
|
|
61
|
+
throw new TypeError('Honk: no fetch available; use Node 18+ or pass options.fetch');
|
|
62
|
+
this.#fetch = f;
|
|
63
|
+
this.#userAgent = `honk-me-node/${VERSION}${options.userAgent ? ` ${options.userAgent}` : ''}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reads `HONK_URL`, `HONK_KEY` and optionally `HONK_SOURCE`, `HONK_ENVIRONMENT`, `HONK_CHANNEL`
|
|
67
|
+
* from the environment. `options` override them.
|
|
68
|
+
*/
|
|
69
|
+
static fromEnv(options = {}) {
|
|
70
|
+
const env = (name) => {
|
|
71
|
+
const g = globalThis;
|
|
72
|
+
return g.process?.env?.[name] ?? g.Deno?.env?.get(name);
|
|
73
|
+
};
|
|
74
|
+
return new Honk({
|
|
75
|
+
...options,
|
|
76
|
+
url: options.url ?? env('HONK_URL') ?? '',
|
|
77
|
+
key: options.key ?? env('HONK_KEY') ?? '',
|
|
78
|
+
defaults: {
|
|
79
|
+
source: env('HONK_SOURCE'),
|
|
80
|
+
environment: env('HONK_ENVIRONMENT'),
|
|
81
|
+
channel: env('HONK_CHANNEL'),
|
|
82
|
+
...options.defaults,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Sends one event. Resolves once Honk has durably stored it (`202`), which does not mean a push
|
|
88
|
+
* was delivered. Retries network errors, 429 and 5xx with the same Idempotency-Key until
|
|
89
|
+
* `retries` or `deadlineMs` runs out, then throws a {@link HonkError}.
|
|
90
|
+
*/
|
|
91
|
+
async send(message, options = {}) {
|
|
92
|
+
const body = JSON.stringify(buildBody(message, this.defaults, this.validate));
|
|
93
|
+
const idempotencyKey = options.idempotencyKey === undefined ? await uuidv7() : checkIdempotencyKey(options.idempotencyKey);
|
|
94
|
+
const signal = options.signal;
|
|
95
|
+
const started = Date.now();
|
|
96
|
+
const deadline = started + this.deadlineMs;
|
|
97
|
+
for (let attempt = 1;; attempt++) {
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
let failure;
|
|
100
|
+
try {
|
|
101
|
+
const res = await this.#post(body, idempotencyKey, Math.max(1, Math.min(this.timeoutMs, deadline - Date.now())), signal);
|
|
102
|
+
if (res.status >= 200 && res.status < 300)
|
|
103
|
+
return accepted(res, idempotencyKey, attempt);
|
|
104
|
+
failure = errorFor(res, idempotencyKey, attempt);
|
|
105
|
+
if (!(failure instanceof HonkQuotaError || failure instanceof HonkServerError))
|
|
106
|
+
throw failure;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err instanceof HonkError && !(err instanceof HonkQuotaError || err instanceof HonkServerError))
|
|
110
|
+
throw err;
|
|
111
|
+
if (signal?.aborted)
|
|
112
|
+
throw signal.reason;
|
|
113
|
+
failure = err instanceof HonkError ? err : networkError(err, idempotencyKey, attempt);
|
|
114
|
+
}
|
|
115
|
+
if (attempt > this.retries)
|
|
116
|
+
throw failure;
|
|
117
|
+
const jitter = Math.random() * Math.min(this.#backoffMax, this.#backoffBase * 2 ** (attempt - 1));
|
|
118
|
+
const wait = Math.max(jitter, (failure.retryAfter ?? 0) * 1000);
|
|
119
|
+
if (Date.now() + wait >= deadline)
|
|
120
|
+
throw failure;
|
|
121
|
+
await sleep(wait, signal);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** A `problem` for `groupKey` (opens or continues an incident). Severity defaults to `long` (error). */
|
|
125
|
+
problem(groupKey, title, message, options = {}) {
|
|
126
|
+
return this.#helper({ ...options, severity: options.severity ?? 'error', groupKey, eventType: 'problem' }, title, message);
|
|
127
|
+
}
|
|
128
|
+
/** A `recovery` for `groupKey` (closes its open incident). Severity defaults to `beep` (success). */
|
|
129
|
+
recovery(groupKey, title, message, options = {}) {
|
|
130
|
+
return this.#helper({ ...options, severity: options.severity ?? 'success', groupKey, eventType: 'recovery' }, title, message);
|
|
131
|
+
}
|
|
132
|
+
/** A light honk (severity info). */
|
|
133
|
+
light(title, message, options = {}) {
|
|
134
|
+
return this.#severity('info', title, message, options);
|
|
135
|
+
}
|
|
136
|
+
/** A beep-beep (severity success). */
|
|
137
|
+
beep(title, message, options = {}) {
|
|
138
|
+
return this.#severity('success', title, message, options);
|
|
139
|
+
}
|
|
140
|
+
/** A loud honk (severity warning). */
|
|
141
|
+
loud(title, message, options = {}) {
|
|
142
|
+
return this.#severity('warning', title, message, options);
|
|
143
|
+
}
|
|
144
|
+
/** A long honk (severity error; pushes at least as high priority). */
|
|
145
|
+
long(title, message, options = {}) {
|
|
146
|
+
return this.#severity('error', title, message, options);
|
|
147
|
+
}
|
|
148
|
+
/** A blast (severity critical; pushes at least as high priority). */
|
|
149
|
+
blast(title, message, options = {}) {
|
|
150
|
+
return this.#severity('critical', title, message, options);
|
|
151
|
+
}
|
|
152
|
+
/** Synonym of {@link light}. */
|
|
153
|
+
info(title, message, options = {}) {
|
|
154
|
+
return this.#severity('info', title, message, options);
|
|
155
|
+
}
|
|
156
|
+
/** Synonym of {@link beep}. */
|
|
157
|
+
success(title, message, options = {}) {
|
|
158
|
+
return this.#severity('success', title, message, options);
|
|
159
|
+
}
|
|
160
|
+
/** Synonym of {@link loud}. */
|
|
161
|
+
warning(title, message, options = {}) {
|
|
162
|
+
return this.#severity('warning', title, message, options);
|
|
163
|
+
}
|
|
164
|
+
/** Synonym of {@link long}. */
|
|
165
|
+
error(title, message, options = {}) {
|
|
166
|
+
return this.#severity('error', title, message, options);
|
|
167
|
+
}
|
|
168
|
+
/** Synonym of {@link blast}. */
|
|
169
|
+
critical(title, message, options = {}) {
|
|
170
|
+
return this.#severity('critical', title, message, options);
|
|
171
|
+
}
|
|
172
|
+
#severity(severity, title, message, options) {
|
|
173
|
+
return this.#helper({ ...options, severity }, title, message);
|
|
174
|
+
}
|
|
175
|
+
#helper(options, title, message) {
|
|
176
|
+
const { idempotencyKey, signal, ...fields } = options;
|
|
177
|
+
return this.send({ ...fields, title, message }, { idempotencyKey, signal });
|
|
178
|
+
}
|
|
179
|
+
async #post(body, idempotencyKey, timeoutMs, signal) {
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const timeout = new TimeoutSignal();
|
|
182
|
+
const timer = setTimeout(() => controller.abort(timeout), timeoutMs);
|
|
183
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
184
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
185
|
+
try {
|
|
186
|
+
const res = await this.#fetch(`${this.url}/v1/messages`, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: {
|
|
189
|
+
authorization: `Bearer ${this.#key}`,
|
|
190
|
+
'idempotency-key': idempotencyKey,
|
|
191
|
+
'content-type': 'application/json',
|
|
192
|
+
accept: 'application/json',
|
|
193
|
+
'user-agent': this.#userAgent,
|
|
194
|
+
},
|
|
195
|
+
body,
|
|
196
|
+
redirect: 'manual',
|
|
197
|
+
signal: controller.signal,
|
|
198
|
+
});
|
|
199
|
+
const text = await res.text();
|
|
200
|
+
return { status: res.status, headers: res.headers, text };
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (controller.signal.reason === timeout)
|
|
204
|
+
throw new TimeoutSignalError(timeoutMs);
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
signal?.removeEventListener('abort', onAbort);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
class TimeoutSignalError extends Error {
|
|
214
|
+
timeoutMs;
|
|
215
|
+
constructor(timeoutMs) {
|
|
216
|
+
super(`attempt timed out after ${timeoutMs} ms`);
|
|
217
|
+
this.timeoutMs = timeoutMs;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function accepted(res, idempotencyKey, attempts) {
|
|
221
|
+
const data = parseJson(res.text);
|
|
222
|
+
if (!data || typeof data.id !== 'string') {
|
|
223
|
+
throw new HonkError(`Honk answered ${res.status} without a message id`, { status: res.status, idempotencyKey, attempts, body: data ?? res.text });
|
|
224
|
+
}
|
|
225
|
+
const receivedAt = typeof data.received_at === 'string' ? new Date(data.received_at) : new Date(Number.NaN);
|
|
226
|
+
return { id: data.id, duplicate: data.duplicate === true, receivedAt };
|
|
227
|
+
}
|
|
228
|
+
function errorFor(res, idempotencyKey, attempts) {
|
|
229
|
+
const data = parseJson(res.text);
|
|
230
|
+
const e = data?.error;
|
|
231
|
+
const code = typeof e?.code === 'string' ? e.code : undefined;
|
|
232
|
+
const detail = typeof e?.message === 'string' ? e.message : res.text.slice(0, 200).trim() || `HTTP ${res.status}`;
|
|
233
|
+
const init = {
|
|
234
|
+
status: res.status,
|
|
235
|
+
code,
|
|
236
|
+
requestId: typeof e?.request_id === 'string' ? e.request_id : (res.headers.get('x-request-id') ?? undefined),
|
|
237
|
+
idempotencyKey,
|
|
238
|
+
attempts,
|
|
239
|
+
retryAfter: parseRetryAfter(res.headers.get('retry-after')),
|
|
240
|
+
body: data ?? (res.text || undefined),
|
|
241
|
+
};
|
|
242
|
+
const s = res.status;
|
|
243
|
+
const label = `Honk ${s}${code ? ` ${code}` : ''}: ${detail}`;
|
|
244
|
+
if (s === 400 || s === 413 || s === 415 || s === 422) {
|
|
245
|
+
const fields = Array.isArray(e?.fields) ? e.fields : [];
|
|
246
|
+
const list = fields.map((f) => `${f.field} ${f.message ?? f.code}`).join('; ');
|
|
247
|
+
return new HonkValidationError(list ? `${label} (${list})` : label, { ...init, fields });
|
|
248
|
+
}
|
|
249
|
+
if (s === 401 || s === 403)
|
|
250
|
+
return new HonkAuthError(label, init);
|
|
251
|
+
if (s === 409)
|
|
252
|
+
return new HonkConflictError(label, init);
|
|
253
|
+
if (s === 429)
|
|
254
|
+
return new HonkQuotaError(label, init);
|
|
255
|
+
if (s >= 500)
|
|
256
|
+
return new HonkServerError(label, init);
|
|
257
|
+
if (s >= 300 && s < 400) {
|
|
258
|
+
const to = res.headers.get('location');
|
|
259
|
+
return new HonkError(`Honk answered ${s} redirect${to ? ` to ${to}` : ''}; set url to the final https address`, init);
|
|
260
|
+
}
|
|
261
|
+
if (s === 404)
|
|
262
|
+
return new HonkError(`${label} (is url the base address of your Honk server?)`, init);
|
|
263
|
+
return new HonkError(label, init);
|
|
264
|
+
}
|
|
265
|
+
function networkError(err, idempotencyKey, attempts) {
|
|
266
|
+
if (err instanceof TimeoutSignalError) {
|
|
267
|
+
return new HonkTimeoutError(`Honk did not answer: ${err.message} (the event may or may not have been stored; retrying with the same idempotency key is safe)`, {
|
|
268
|
+
code: 'timeout',
|
|
269
|
+
idempotencyKey,
|
|
270
|
+
attempts,
|
|
271
|
+
cause: err,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const cause = err?.cause;
|
|
275
|
+
const reason = cause?.code ?? cause?.message ?? err?.message ?? String(err);
|
|
276
|
+
return new HonkNetworkError(`Could not reach Honk: ${reason}`, { code: 'network_error', idempotencyKey, attempts, cause: err });
|
|
277
|
+
}
|
|
278
|
+
function parseJson(text) {
|
|
279
|
+
if (!text)
|
|
280
|
+
return undefined;
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(text);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function sleep(ms, signal) {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
if (signal?.aborted)
|
|
291
|
+
return reject(signal.reason);
|
|
292
|
+
const onAbort = () => {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
reject(signal?.reason);
|
|
295
|
+
};
|
|
296
|
+
const timer = setTimeout(() => {
|
|
297
|
+
signal?.removeEventListener('abort', onAbort);
|
|
298
|
+
resolve();
|
|
299
|
+
}, ms);
|
|
300
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function normalizeUrl(url) {
|
|
304
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
305
|
+
throw new TypeError('Honk: url is required (the base address of your Honk server, e.g. https://honk.example.com; is HONK_URL set?)');
|
|
306
|
+
}
|
|
307
|
+
let u = url.trim().replace(/\/+$/, '');
|
|
308
|
+
u = u.replace(/\/v1\/messages$/, '');
|
|
309
|
+
if (!/^https?:\/\/[^/]+/i.test(u))
|
|
310
|
+
throw new TypeError(`Honk: url must start with https:// (got ${JSON.stringify(url)})`);
|
|
311
|
+
return u;
|
|
312
|
+
}
|
|
313
|
+
function normalizeKey(key) {
|
|
314
|
+
if (typeof key !== 'string' || key.trim() === '') {
|
|
315
|
+
throw new TypeError('Honk: key is required (a project ingestion key honk_…; is HONK_KEY set?)');
|
|
316
|
+
}
|
|
317
|
+
const k = key.trim();
|
|
318
|
+
if (!/^honk_[\x21-\x7e]+$/.test(k)) {
|
|
319
|
+
throw new TypeError('Honk: key must be a project ingestion key starting with honk_ (create one under Project → Keys)');
|
|
320
|
+
}
|
|
321
|
+
return k;
|
|
322
|
+
}
|
|
323
|
+
function positive(v, def, name) {
|
|
324
|
+
if (v === undefined)
|
|
325
|
+
return def;
|
|
326
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0)
|
|
327
|
+
throw new TypeError(`Honk: ${name} must be a positive number of milliseconds`);
|
|
328
|
+
return v;
|
|
329
|
+
}
|