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 ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to `honk-me` (npm) are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-10-04
8
+
9
+ ### Added
10
+ - `Honk` client for `POST /v1/messages` with every field of the v1 ingestion API
11
+ (including `imageUrl`), camelCase in, snake_case on the wire.
12
+ - Automatic UUIDv7 `Idempotency-Key` (or your own), reused on every retry.
13
+ - Retries for network errors, timeouts, 429 and 5xx with exponential backoff, full jitter,
14
+ `Retry-After` and a total deadline.
15
+ - Typed errors: `HonkValidationError`, `HonkAuthError`, `HonkQuotaError`,
16
+ `HonkConflictError`, `HonkNetworkError`, `HonkTimeoutError`, `HonkServerError`.
17
+ - Local validation of limits, enums and https-only URLs, with every invalid field reported.
18
+ - Helpers `problem`, `recovery`, `info`, `success`, `warning`, `error`, `critical`;
19
+ `Honk.fromEnv()`.
20
+ - ESM + CommonJS builds with TypeScript types, zero runtime dependencies; Node 18+, Bun, Deno.
21
+ - The Honk scale: severity horn aliases `light` (info), `beep` (success), `loud` (warning),
22
+ `long` (error), `blast` (critical), case-insensitive and always sent canonical; `Severity`
23
+ constants (`Severity.Loud === 'warning'`), `normalizeSeverity()` and the `light`, `beep`,
24
+ `loud`, `long`, `blast` helpers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Honk contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # honk-me
2
+
3
+ [![CI](https://github.com/honk-me/honk-node/actions/workflows/ci.yml/badge.svg)](https://github.com/honk-me/honk-node/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/honk-me)](https://www.npmjs.com/package/honk-me)
5
+
6
+ Official Node.js / TypeScript client for [Honk](https://honk-me.app), the inbox that turns
7
+ events from your apps, scripts, cron jobs and CI into calm, grouped push notifications on your
8
+ phone.
9
+
10
+ - Zero runtime dependencies. ESM, CommonJS and TypeScript types.
11
+ - Node 18+ (global `fetch`). Also works on Bun and Deno (`npm:honk-me`).
12
+ - Retries with backoff, `Retry-After`, a total deadline and an idempotency key on every send,
13
+ so a retry never creates a duplicate.
14
+
15
+ > **Keep the key on the server.** A Honk ingestion key (`honk_…`) lets anyone post into your
16
+ > project. Use this package in back-end code, functions and scripts only. Never bundle it
17
+ > into a browser, mobile or desktop front end, and never commit it. Read it from the
18
+ > environment.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm install honk-me
24
+ ```
25
+
26
+ Create a project and an ingestion key at [honk-me.app](https://honk-me.app). Its
27
+ *Integrations* page generates ready-to-paste code for this package.
28
+
29
+ ## Quick start
30
+
31
+ ```js
32
+ import { Honk } from 'honk-me';
33
+
34
+ const honk = new Honk({ url: process.env.HONK_URL, key: process.env.HONK_KEY });
35
+ await honk.beep('Backup finished', 'nightly pg_dump took 42 s');
36
+ ```
37
+
38
+ `Honk.fromEnv()` reads `HONK_URL`, `HONK_KEY` and the optional `HONK_SOURCE`,
39
+ `HONK_ENVIRONMENT` and `HONK_CHANNEL` defaults. CommonJS:
40
+ `const { Honk } = require('honk-me')`.
41
+
42
+ ## The Honk scale
43
+
44
+ Every severity has a horn name. Use either; the SDK always sends the canonical value.
45
+
46
+ | Horn | Severity | Helper | Constant |
47
+ |---|---|---|---|
48
+ | light honk | `light` (info) | `honk.light(title, message, opts)` | `Severity.Light` |
49
+ | beep-beep | `beep` (success) | `honk.beep(…)` | `Severity.Beep` |
50
+ | loud honk | `loud` (warning) | `honk.loud(…)` | `Severity.Loud` |
51
+ | long honk | `long` (error) | `honk.long(…)` | `Severity.Long` |
52
+ | blast | `blast` (critical) | `honk.blast(…)` | `Severity.Blast` |
53
+
54
+ `severity: 'loud'`, `'LOUD'` and `'warning'` are the same event (also for idempotency).
55
+ `long` and `blast` push at least as `high` priority. `info()`, `success()`, `warning()`,
56
+ `error()` and `critical()` remain as synonyms; `normalizeSeverity('Blast') === 'critical'`.
57
+
58
+ ## Recipe: notify me when a customer asks for something
59
+
60
+ Give each request its own group (`requests/<id>`) and its own idempotency key. Two different
61
+ customers never fold into one notification, and a retried webhook or job never buzzes twice.
62
+
63
+ ```js
64
+ import { Honk } from 'honk-me';
65
+
66
+ const honk = Honk.fromEnv(); // create once, reuse (keep-alive)
67
+
68
+ export async function onCustomerRequest(req) {
69
+ // ...save the request first, then notify without blocking the response:
70
+ honk
71
+ .send(
72
+ {
73
+ title: `New request: ${req.subject}`.slice(0, 160),
74
+ message: `${req.name} (${req.company}) asked: ${req.body}`.slice(0, 2000),
75
+ priority: 'high', // push right away
76
+ category: 'customers',
77
+ channel: 'requests',
78
+ groupKey: `requests/${req.id}`, // one group per request
79
+ url: `https://shop.example.com/admin/requests/${req.id}`, // https only, shown as "Open link"
80
+ metadata: { request_id: String(req.id) },
81
+ },
82
+ { idempotencyKey: `request-${req.id}` }, // same request, same key: never a duplicate
83
+ )
84
+ .catch((err) => console.warn('honk:', err.message)); // never fail the customer's request
85
+ }
86
+ ```
87
+
88
+ In a queue worker (BullMQ, SQS, …) `await` the call and let the job retry on
89
+ `err.retryable`, keeping the same `idempotencyKey`.
90
+
91
+ ## Grouping in three lines
92
+
93
+ Messages with the same `groupKey` (per project, environment, source and channel) form one
94
+ group: the first one pushes, repeats update it calmly instead of buzzing again. Use one key
95
+ per customer request (`requests/<id>`), and a shared key only for repeats of the same problem
96
+ (`queue/failed-jobs`). `problem`/`recovery` pairs need a `groupKey`.
97
+
98
+ ## Sending
99
+
100
+ ```ts
101
+ honk.send(message, { idempotencyKey?, signal? }) // → Promise<{ id, duplicate, receivedAt }>
102
+ ```
103
+
104
+ | Field | Type | Notes |
105
+ |---|---|---|
106
+ | `message` | string | **required**, 1–8192 bytes UTF-8, line breaks allowed |
107
+ | `title` | string | ≤ 160 chars, one line; default: first line of `message` |
108
+ | `severity` | `light` `beep` `loud` `long` `blast` (or the canonical names, any case) | default `light`; `long`/`blast` push at least as `high` |
109
+ | `priority` | `low` `normal` `high` `urgent` | default `normal`; `urgent` needs a key with *allow urgent* |
110
+ | `category` | `infrastructure` `security` `backups` `deployments` `payments` `customers` `sales` `automation` `personal` `other` | |
111
+ | `source` / `environment` / `channel` | string | ≤ 64 / 32 / 64 chars; default `api` / `default` / `general` or the client `defaults` |
112
+ | `groupKey` | string | ≤ 128 chars |
113
+ | `eventType` | `event` `problem` `recovery` | `recovery` needs `groupKey` |
114
+ | `occurredAt` | `Date` or RFC 3339 string | informational |
115
+ | `url` | string | `https://` only, no credentials |
116
+ | `imageUrl` | string | `https://` only, no credentials or `#fragment`; fetched by the server afterwards |
117
+ | `metadata` | `Record<string, string \| number \| boolean>` | ≤ 16 keys `[A-Za-z0-9_.-]{1,64}`, strings ≤ 512 chars |
118
+ | `ttlSeconds` | integer | push lifetime 60–86400, default 3600 |
119
+ | `sourceSequence` | integer | 0 … 2^53-1, needs `groupKey`; a delayed recovery never closes a newer problem |
120
+
121
+ Fields are camelCase in this SDK and sent with the API's snake_case names. `null`, `undefined`
122
+ and empty optional strings are omitted. A resolved promise means Honk **durably stored** the
123
+ message (`202`); it does not mean a push was delivered or read.
124
+
125
+ Helpers (the last argument takes any message field plus `idempotencyKey` and `signal`):
126
+
127
+ ```js
128
+ await honk.loud('Disk 91%', '/var on app-01', { groupKey: 'disk/app-01/var' });
129
+ await honk.light('Deploy started', 'v4.2.0 to production', { channel: 'deploys' });
130
+ await honk.beep(title, message, opts); await honk.long(title, message, opts); await honk.blast(title, message, opts);
131
+ await honk.problem('db/backup', 'Backup failed', 'pg_dump exited with 1'); // a long honk by default
132
+ await honk.recovery('db/backup', 'Backup OK', 'pg_dump finished in 41 s'); // a beep by default
133
+ ```
134
+
135
+ ### Options
136
+
137
+ ```js
138
+ new Honk({
139
+ url: 'https://honk.example.com', // base URL of your server
140
+ key: 'honk_…', // project ingestion key
141
+ timeoutMs: 5000, // per attempt
142
+ retries: 4, // after the first attempt
143
+ deadlineMs: 30000, // total, waits included
144
+ defaults: { source: 'billing-api', environment: 'production', channel: 'payments' },
145
+ validate: true, // local checks before sending (the server always validates)
146
+ backoff: { baseMs: 500, maxMs: 8000 },
147
+ fetch: customFetch, // e.g. undici with a proxy agent
148
+ });
149
+ ```
150
+
151
+ ## Retries and idempotency, guaranteed
152
+
153
+ - Every `send()` carries an `Idempotency-Key`: yours, or a fresh UUIDv7. **The same key is
154
+ reused on every retry.** Within 24 h Honk answers a replay with the original id and
155
+ `duplicate: true`, so a lost response never creates a second message.
156
+ - Only network errors, timeouts, `429` and `5xx` are retried, with exponential backoff and
157
+ full jitter (`random(0, min(8 s, 0.5 s·2ⁿ))`), never sooner than the server's `Retry-After`.
158
+ - Everything stops at `deadlineMs`: if the next wait would cross it (for example a daily quota
159
+ that resets at midnight), the error is thrown at once with `retryAfter`.
160
+ - `4xx` other than `429` are never retried: fix the request instead.
161
+ - Short per-attempt timeouts; Node's `fetch` keeps connections alive, so create one `Honk`
162
+ per process and reuse it.
163
+ - Pass your own stable key (`request-4812`, `deploy-${sha}`) to stay duplicate-free across
164
+ process restarts and job retries. Reusing a key with a *different* payload is a conflict.
165
+
166
+ ## Errors
167
+
168
+ Every error is a `HonkError` with `kind`, `status`, `code`, `requestId`, `idempotencyKey`,
169
+ `attempts`, `retryAfter` and `retryable`.
170
+
171
+ | Class | `kind` | When | What to do |
172
+ |---|---|---|---|
173
+ | `HonkValidationError` | `validation` | rejected locally (`local: true`) or `400`/`413`/`415`/`422`; `fields` lists every problem | fix the message |
174
+ | `HonkAuthError` | `auth` | `401 invalid_key`, `403 priority_not_allowed`, `project_suspended`, `workspace_suspended` | fix the key or the priority |
175
+ | `HonkQuotaError` | `quota` | `429 quota_exceeded` (daily, resets at UTC midnight) or `rate_limited`, after retries | retry after `retryAfter` seconds |
176
+ | `HonkConflictError` | `conflict` | `409 idempotency_conflict`: same key, different payload | use a new key, or send the original payload |
177
+ | `HonkNetworkError` | `network` | unreachable on every attempt | retry later, same key |
178
+ | `HonkTimeoutError` | `timeout` | attempts timed out; the message may or may not be stored | retry later, same key |
179
+ | `HonkServerError` | `server` | `5xx` on every attempt | retry later, same key |
180
+
181
+ ```js
182
+ import { HonkError, HonkValidationError } from 'honk-me';
183
+
184
+ try {
185
+ await honk.send(msg, { idempotencyKey: `order-${order.id}-failed` });
186
+ } catch (err) {
187
+ if (err instanceof HonkValidationError) logger.error(err.fields); // a bug: don't retry
188
+ else if (err instanceof HonkError && err.retryable) queue.retryLater(err.idempotencyKey, err.retryAfter);
189
+ else throw err;
190
+ }
191
+ ```
192
+
193
+ `kind` is also handy when two copies of the package are loaded and `instanceof` can fail.
194
+
195
+ ## Bun and Deno
196
+
197
+ ```ts
198
+ import { Honk } from 'npm:honk-me'; // Deno
199
+ const honk = new Honk({ url: Deno.env.get('HONK_URL')!, key: Deno.env.get('HONK_KEY')! });
200
+ ```
201
+
202
+ Bun: `bun add honk-me`, then use it as in Node.
203
+
204
+ ## Development
205
+
206
+ ```sh
207
+ npm install
208
+ npm test # build + unit tests (mock server) + CommonJS smoke test
209
+ npm run typecheck # also checks the published .d.ts from ESM and CJS consumers
210
+ HONK_URL=… HONK_KEY=… npm run test:integration # against a real server (use a test project's key)
211
+ ```
212
+
213
+ The version lives in `package.json`; the build regenerates `src/version.ts` (`VERSION`, the
214
+ User-Agent) from it. Releases: push a tag `vX.Y.Z` matching `package.json` and the release
215
+ workflow publishes to npm with provenance (see `CHANGELOG.md`).
216
+
217
+ ## Links
218
+
219
+ - [honk-me.app](https://honk-me.app): the Honk inbox (web, iPhone).
220
+ - Other SDKs: [PHP / Laravel](https://github.com/honk-me/honk-php),
221
+ [Go + CLI](https://github.com/honk-me/honk-go), [Swift](https://github.com/honk-me/honk-swift),
222
+ [Kotlin / Java](https://github.com/honk-me/honk-kotlin).
223
+
224
+ MIT License.
@@ -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,334 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Honk = exports.VERSION = void 0;
4
+ exports.parseRetryAfter = parseRetryAfter;
5
+ const errors_js_1 = require("./errors.js");
6
+ const uuid_js_1 = require("./uuid.js");
7
+ const validate_js_1 = require("./validate.js");
8
+ const version_js_1 = require("./version.js");
9
+ Object.defineProperty(exports, "VERSION", { enumerable: true, get: function () { return version_js_1.VERSION; } });
10
+ const DEFAULT_TIMEOUT_MS = 5_000;
11
+ const DEFAULT_RETRIES = 4;
12
+ const DEFAULT_DEADLINE_MS = 30_000;
13
+ const DEFAULT_BACKOFF_BASE_MS = 500;
14
+ const DEFAULT_BACKOFF_MAX_MS = 8_000;
15
+ /** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
16
+ function parseRetryAfter(value, now = Date.now()) {
17
+ if (value === null)
18
+ return undefined;
19
+ const v = value.trim();
20
+ if (/^\d+$/.test(v))
21
+ return Number(v);
22
+ const at = Date.parse(v);
23
+ if (Number.isNaN(at))
24
+ return undefined;
25
+ return Math.max(0, Math.ceil((at - now) / 1000));
26
+ }
27
+ class TimeoutSignal {
28
+ }
29
+ /**
30
+ * Client for `POST /v1/messages`. Create one per process and reuse it (connections are kept alive).
31
+ *
32
+ * ```ts
33
+ * const honk = new Honk({ url: process.env.HONK_URL!, key: process.env.HONK_KEY! });
34
+ * await honk.send({ title: 'Backup finished', message: 'nightly pg_dump took 42 s', severity: 'success' });
35
+ * ```
36
+ */
37
+ class Honk {
38
+ url;
39
+ timeoutMs;
40
+ retries;
41
+ deadlineMs;
42
+ defaults;
43
+ validate;
44
+ #key;
45
+ #fetch;
46
+ #backoffBase;
47
+ #backoffMax;
48
+ #userAgent;
49
+ constructor(options) {
50
+ if (options === null || typeof options !== 'object')
51
+ throw new TypeError('Honk: pass options like { url, key }');
52
+ this.url = normalizeUrl(options.url);
53
+ this.#key = normalizeKey(options.key);
54
+ this.timeoutMs = positive(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
55
+ this.deadlineMs = positive(options.deadlineMs, DEFAULT_DEADLINE_MS, 'deadlineMs');
56
+ this.retries = options.retries ?? DEFAULT_RETRIES;
57
+ if (!Number.isInteger(this.retries) || this.retries < 0)
58
+ throw new TypeError('Honk: retries must be an integer ≥ 0');
59
+ this.#backoffBase = positive(options.backoff?.baseMs, DEFAULT_BACKOFF_BASE_MS, 'backoff.baseMs');
60
+ this.#backoffMax = positive(options.backoff?.maxMs, DEFAULT_BACKOFF_MAX_MS, 'backoff.maxMs');
61
+ this.defaults = Object.freeze({ ...(options.defaults ?? {}) });
62
+ this.validate = options.validate ?? true;
63
+ const f = options.fetch ?? globalThis.fetch;
64
+ if (typeof f !== 'function')
65
+ throw new TypeError('Honk: no fetch available; use Node 18+ or pass options.fetch');
66
+ this.#fetch = f;
67
+ this.#userAgent = `honk-me-node/${version_js_1.VERSION}${options.userAgent ? ` ${options.userAgent}` : ''}`;
68
+ }
69
+ /**
70
+ * Reads `HONK_URL`, `HONK_KEY` and optionally `HONK_SOURCE`, `HONK_ENVIRONMENT`, `HONK_CHANNEL`
71
+ * from the environment. `options` override them.
72
+ */
73
+ static fromEnv(options = {}) {
74
+ const env = (name) => {
75
+ const g = globalThis;
76
+ return g.process?.env?.[name] ?? g.Deno?.env?.get(name);
77
+ };
78
+ return new Honk({
79
+ ...options,
80
+ url: options.url ?? env('HONK_URL') ?? '',
81
+ key: options.key ?? env('HONK_KEY') ?? '',
82
+ defaults: {
83
+ source: env('HONK_SOURCE'),
84
+ environment: env('HONK_ENVIRONMENT'),
85
+ channel: env('HONK_CHANNEL'),
86
+ ...options.defaults,
87
+ },
88
+ });
89
+ }
90
+ /**
91
+ * Sends one event. Resolves once Honk has durably stored it (`202`), which does not mean a push
92
+ * was delivered. Retries network errors, 429 and 5xx with the same Idempotency-Key until
93
+ * `retries` or `deadlineMs` runs out, then throws a {@link HonkError}.
94
+ */
95
+ async send(message, options = {}) {
96
+ const body = JSON.stringify((0, validate_js_1.buildBody)(message, this.defaults, this.validate));
97
+ const idempotencyKey = options.idempotencyKey === undefined ? await (0, uuid_js_1.uuidv7)() : (0, validate_js_1.checkIdempotencyKey)(options.idempotencyKey);
98
+ const signal = options.signal;
99
+ const started = Date.now();
100
+ const deadline = started + this.deadlineMs;
101
+ for (let attempt = 1;; attempt++) {
102
+ signal?.throwIfAborted();
103
+ let failure;
104
+ try {
105
+ const res = await this.#post(body, idempotencyKey, Math.max(1, Math.min(this.timeoutMs, deadline - Date.now())), signal);
106
+ if (res.status >= 200 && res.status < 300)
107
+ return accepted(res, idempotencyKey, attempt);
108
+ failure = errorFor(res, idempotencyKey, attempt);
109
+ if (!(failure instanceof errors_js_1.HonkQuotaError || failure instanceof errors_js_1.HonkServerError))
110
+ throw failure;
111
+ }
112
+ catch (err) {
113
+ if (err instanceof errors_js_1.HonkError && !(err instanceof errors_js_1.HonkQuotaError || err instanceof errors_js_1.HonkServerError))
114
+ throw err;
115
+ if (signal?.aborted)
116
+ throw signal.reason;
117
+ failure = err instanceof errors_js_1.HonkError ? err : networkError(err, idempotencyKey, attempt);
118
+ }
119
+ if (attempt > this.retries)
120
+ throw failure;
121
+ const jitter = Math.random() * Math.min(this.#backoffMax, this.#backoffBase * 2 ** (attempt - 1));
122
+ const wait = Math.max(jitter, (failure.retryAfter ?? 0) * 1000);
123
+ if (Date.now() + wait >= deadline)
124
+ throw failure;
125
+ await sleep(wait, signal);
126
+ }
127
+ }
128
+ /** A `problem` for `groupKey` (opens or continues an incident). Severity defaults to `long` (error). */
129
+ problem(groupKey, title, message, options = {}) {
130
+ return this.#helper({ ...options, severity: options.severity ?? 'error', groupKey, eventType: 'problem' }, title, message);
131
+ }
132
+ /** A `recovery` for `groupKey` (closes its open incident). Severity defaults to `beep` (success). */
133
+ recovery(groupKey, title, message, options = {}) {
134
+ return this.#helper({ ...options, severity: options.severity ?? 'success', groupKey, eventType: 'recovery' }, title, message);
135
+ }
136
+ /** A light honk (severity info). */
137
+ light(title, message, options = {}) {
138
+ return this.#severity('info', title, message, options);
139
+ }
140
+ /** A beep-beep (severity success). */
141
+ beep(title, message, options = {}) {
142
+ return this.#severity('success', title, message, options);
143
+ }
144
+ /** A loud honk (severity warning). */
145
+ loud(title, message, options = {}) {
146
+ return this.#severity('warning', title, message, options);
147
+ }
148
+ /** A long honk (severity error; pushes at least as high priority). */
149
+ long(title, message, options = {}) {
150
+ return this.#severity('error', title, message, options);
151
+ }
152
+ /** A blast (severity critical; pushes at least as high priority). */
153
+ blast(title, message, options = {}) {
154
+ return this.#severity('critical', title, message, options);
155
+ }
156
+ /** Synonym of {@link light}. */
157
+ info(title, message, options = {}) {
158
+ return this.#severity('info', title, message, options);
159
+ }
160
+ /** Synonym of {@link beep}. */
161
+ success(title, message, options = {}) {
162
+ return this.#severity('success', title, message, options);
163
+ }
164
+ /** Synonym of {@link loud}. */
165
+ warning(title, message, options = {}) {
166
+ return this.#severity('warning', title, message, options);
167
+ }
168
+ /** Synonym of {@link long}. */
169
+ error(title, message, options = {}) {
170
+ return this.#severity('error', title, message, options);
171
+ }
172
+ /** Synonym of {@link blast}. */
173
+ critical(title, message, options = {}) {
174
+ return this.#severity('critical', title, message, options);
175
+ }
176
+ #severity(severity, title, message, options) {
177
+ return this.#helper({ ...options, severity }, title, message);
178
+ }
179
+ #helper(options, title, message) {
180
+ const { idempotencyKey, signal, ...fields } = options;
181
+ return this.send({ ...fields, title, message }, { idempotencyKey, signal });
182
+ }
183
+ async #post(body, idempotencyKey, timeoutMs, signal) {
184
+ const controller = new AbortController();
185
+ const timeout = new TimeoutSignal();
186
+ const timer = setTimeout(() => controller.abort(timeout), timeoutMs);
187
+ const onAbort = () => controller.abort(signal?.reason);
188
+ signal?.addEventListener('abort', onAbort, { once: true });
189
+ try {
190
+ const res = await this.#fetch(`${this.url}/v1/messages`, {
191
+ method: 'POST',
192
+ headers: {
193
+ authorization: `Bearer ${this.#key}`,
194
+ 'idempotency-key': idempotencyKey,
195
+ 'content-type': 'application/json',
196
+ accept: 'application/json',
197
+ 'user-agent': this.#userAgent,
198
+ },
199
+ body,
200
+ redirect: 'manual',
201
+ signal: controller.signal,
202
+ });
203
+ const text = await res.text();
204
+ return { status: res.status, headers: res.headers, text };
205
+ }
206
+ catch (err) {
207
+ if (controller.signal.reason === timeout)
208
+ throw new TimeoutSignalError(timeoutMs);
209
+ throw err;
210
+ }
211
+ finally {
212
+ clearTimeout(timer);
213
+ signal?.removeEventListener('abort', onAbort);
214
+ }
215
+ }
216
+ }
217
+ exports.Honk = Honk;
218
+ class TimeoutSignalError extends Error {
219
+ timeoutMs;
220
+ constructor(timeoutMs) {
221
+ super(`attempt timed out after ${timeoutMs} ms`);
222
+ this.timeoutMs = timeoutMs;
223
+ }
224
+ }
225
+ function accepted(res, idempotencyKey, attempts) {
226
+ const data = parseJson(res.text);
227
+ if (!data || typeof data.id !== 'string') {
228
+ throw new errors_js_1.HonkError(`Honk answered ${res.status} without a message id`, { status: res.status, idempotencyKey, attempts, body: data ?? res.text });
229
+ }
230
+ const receivedAt = typeof data.received_at === 'string' ? new Date(data.received_at) : new Date(Number.NaN);
231
+ return { id: data.id, duplicate: data.duplicate === true, receivedAt };
232
+ }
233
+ function errorFor(res, idempotencyKey, attempts) {
234
+ const data = parseJson(res.text);
235
+ const e = data?.error;
236
+ const code = typeof e?.code === 'string' ? e.code : undefined;
237
+ const detail = typeof e?.message === 'string' ? e.message : res.text.slice(0, 200).trim() || `HTTP ${res.status}`;
238
+ const init = {
239
+ status: res.status,
240
+ code,
241
+ requestId: typeof e?.request_id === 'string' ? e.request_id : (res.headers.get('x-request-id') ?? undefined),
242
+ idempotencyKey,
243
+ attempts,
244
+ retryAfter: parseRetryAfter(res.headers.get('retry-after')),
245
+ body: data ?? (res.text || undefined),
246
+ };
247
+ const s = res.status;
248
+ const label = `Honk ${s}${code ? ` ${code}` : ''}: ${detail}`;
249
+ if (s === 400 || s === 413 || s === 415 || s === 422) {
250
+ const fields = Array.isArray(e?.fields) ? e.fields : [];
251
+ const list = fields.map((f) => `${f.field} ${f.message ?? f.code}`).join('; ');
252
+ return new errors_js_1.HonkValidationError(list ? `${label} (${list})` : label, { ...init, fields });
253
+ }
254
+ if (s === 401 || s === 403)
255
+ return new errors_js_1.HonkAuthError(label, init);
256
+ if (s === 409)
257
+ return new errors_js_1.HonkConflictError(label, init);
258
+ if (s === 429)
259
+ return new errors_js_1.HonkQuotaError(label, init);
260
+ if (s >= 500)
261
+ return new errors_js_1.HonkServerError(label, init);
262
+ if (s >= 300 && s < 400) {
263
+ const to = res.headers.get('location');
264
+ return new errors_js_1.HonkError(`Honk answered ${s} redirect${to ? ` to ${to}` : ''}; set url to the final https address`, init);
265
+ }
266
+ if (s === 404)
267
+ return new errors_js_1.HonkError(`${label} (is url the base address of your Honk server?)`, init);
268
+ return new errors_js_1.HonkError(label, init);
269
+ }
270
+ function networkError(err, idempotencyKey, attempts) {
271
+ if (err instanceof TimeoutSignalError) {
272
+ return new errors_js_1.HonkTimeoutError(`Honk did not answer: ${err.message} (the event may or may not have been stored; retrying with the same idempotency key is safe)`, {
273
+ code: 'timeout',
274
+ idempotencyKey,
275
+ attempts,
276
+ cause: err,
277
+ });
278
+ }
279
+ const cause = err?.cause;
280
+ const reason = cause?.code ?? cause?.message ?? err?.message ?? String(err);
281
+ return new errors_js_1.HonkNetworkError(`Could not reach Honk: ${reason}`, { code: 'network_error', idempotencyKey, attempts, cause: err });
282
+ }
283
+ function parseJson(text) {
284
+ if (!text)
285
+ return undefined;
286
+ try {
287
+ return JSON.parse(text);
288
+ }
289
+ catch {
290
+ return undefined;
291
+ }
292
+ }
293
+ function sleep(ms, signal) {
294
+ return new Promise((resolve, reject) => {
295
+ if (signal?.aborted)
296
+ return reject(signal.reason);
297
+ const onAbort = () => {
298
+ clearTimeout(timer);
299
+ reject(signal?.reason);
300
+ };
301
+ const timer = setTimeout(() => {
302
+ signal?.removeEventListener('abort', onAbort);
303
+ resolve();
304
+ }, ms);
305
+ signal?.addEventListener('abort', onAbort, { once: true });
306
+ });
307
+ }
308
+ function normalizeUrl(url) {
309
+ if (typeof url !== 'string' || url.trim() === '') {
310
+ throw new TypeError('Honk: url is required (the base address of your Honk server, e.g. https://honk.example.com; is HONK_URL set?)');
311
+ }
312
+ let u = url.trim().replace(/\/+$/, '');
313
+ u = u.replace(/\/v1\/messages$/, '');
314
+ if (!/^https?:\/\/[^/]+/i.test(u))
315
+ throw new TypeError(`Honk: url must start with https:// (got ${JSON.stringify(url)})`);
316
+ return u;
317
+ }
318
+ function normalizeKey(key) {
319
+ if (typeof key !== 'string' || key.trim() === '') {
320
+ throw new TypeError('Honk: key is required (a project ingestion key honk_…; is HONK_KEY set?)');
321
+ }
322
+ const k = key.trim();
323
+ if (!/^honk_[\x21-\x7e]+$/.test(k)) {
324
+ throw new TypeError('Honk: key must be a project ingestion key starting with honk_ (create one under Project → Keys)');
325
+ }
326
+ return k;
327
+ }
328
+ function positive(v, def, name) {
329
+ if (v === undefined)
330
+ return def;
331
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0)
332
+ throw new TypeError(`Honk: ${name} must be a positive number of milliseconds`);
333
+ return v;
334
+ }