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
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
|
+
[](https://github.com/honk-me/honk-node/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
+
}
|