honk-me 0.1.0 → 0.2.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 +20 -0
- package/README.md +37 -6
- package/dist/cjs/client.js +4 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/types.d.ts +16 -0
- package/dist/cjs/validate.d.ts +8 -0
- package/dist/cjs/validate.js +109 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.js +4 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/types.d.ts +16 -0
- package/dist/esm/validate.d.ts +8 -0
- package/dist/esm/validate.js +108 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to `honk-me` (npm) are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `actions`: up to 3 buttons on a message, `{ title, url }` with an `https://`, `mailto:`,
|
|
11
|
+
`tel:` or `sms:` URL (the `Action` type). Validated locally like the server does, with
|
|
12
|
+
errors named `actions[1].url`; empty `actions` are omitted, so messages without buttons are
|
|
13
|
+
sent exactly as before.
|
|
14
|
+
|
|
15
|
+
## [0.1.1] - 2026-10-06
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Near the deadline, a retry no longer starts with only a few milliseconds left. Such an
|
|
19
|
+
attempt could only time out, and its `HonkTimeoutError` hid the server's real answer (for
|
|
20
|
+
example `503`). A retry now needs at least 250 ms (or `timeoutMs`, when shorter) before
|
|
21
|
+
`deadlineMs`; otherwise the last error is thrown at once.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Package author: Honk <accounts@honk-me.app>. The README links the Rust SDK, the n8n node
|
|
25
|
+
and the WordPress plugin.
|
|
26
|
+
|
|
7
27
|
## [0.1.0] - 2026-10-04
|
|
8
28
|
|
|
9
29
|
### Added
|
package/README.md
CHANGED
|
@@ -114,12 +114,13 @@ honk.send(message, { idempotencyKey?, signal? }) // → Promise<{ id, duplicate,
|
|
|
114
114
|
| `occurredAt` | `Date` or RFC 3339 string | informational |
|
|
115
115
|
| `url` | string | `https://` only, no credentials |
|
|
116
116
|
| `imageUrl` | string | `https://` only, no credentials or `#fragment`; fetched by the server afterwards |
|
|
117
|
+
| `actions` | `{ title, url }[]` | up to 3 buttons, the first is the primary; see below |
|
|
117
118
|
| `metadata` | `Record<string, string \| number \| boolean>` | ≤ 16 keys `[A-Za-z0-9_.-]{1,64}`, strings ≤ 512 chars |
|
|
118
119
|
| `ttlSeconds` | integer | push lifetime 60–86400, default 3600 |
|
|
119
120
|
| `sourceSequence` | integer | 0 … 2^53-1, needs `groupKey`; a delayed recovery never closes a newer problem |
|
|
120
121
|
|
|
121
|
-
Fields are camelCase in this SDK and sent with the API's snake_case names. `null`, `undefined
|
|
122
|
-
|
|
122
|
+
Fields are camelCase in this SDK and sent with the API's snake_case names. `null`, `undefined`,
|
|
123
|
+
empty optional strings and an empty `actions` array are omitted. A resolved promise means Honk **durably stored** the
|
|
123
124
|
message (`202`); it does not mean a push was delivered or read.
|
|
124
125
|
|
|
125
126
|
Helpers (the last argument takes any message field plus `idempotencyKey` and `signal`):
|
|
@@ -132,6 +133,33 @@ await honk.problem('db/backup', 'Backup failed', 'pg_dump exited with 1');
|
|
|
132
133
|
await honk.recovery('db/backup', 'Backup OK', 'pg_dump finished in 41 s'); // a beep by default
|
|
133
134
|
```
|
|
134
135
|
|
|
136
|
+
### Buttons (actions)
|
|
137
|
+
|
|
138
|
+
Up to three buttons on the message, in display order: reply to the customer, call them, open
|
|
139
|
+
the order.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
await honk.send({
|
|
143
|
+
title: 'New request: online shop quote',
|
|
144
|
+
message: 'Emily Carter (Acme) asked for a quote: online shop, 40 products',
|
|
145
|
+
category: 'customers',
|
|
146
|
+
groupKey: 'requests/4812',
|
|
147
|
+
actions: [
|
|
148
|
+
{ title: 'Reply', url: 'mailto:emily@example.com?subject=Your%20quote' },
|
|
149
|
+
{ title: 'Call Emily', url: 'tel:+15550134' },
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- `title`: 1–40 characters, one line, shown as sent.
|
|
155
|
+
- `url`, at most 2048 bytes without spaces: `https://` (no credentials); `mailto:` with one
|
|
156
|
+
address and optionally `?subject=…&body=…` (percent-encoded with `encodeURIComponent`, no
|
|
157
|
+
other keys); `tel:` with a number (digits, `-` `.` `(` `)`, `+` only first); `sms:` with a
|
|
158
|
+
number and optionally `?body=…`. Other schemes are refused.
|
|
159
|
+
- Honk never opens or fetches them; the app does when you tap one. They appear on the
|
|
160
|
+
message in the app and the web inbox, and on iPhone notifications that show the message
|
|
161
|
+
text. Errors name the button: `actions[1].url`.
|
|
162
|
+
|
|
135
163
|
### Options
|
|
136
164
|
|
|
137
165
|
```js
|
|
@@ -155,8 +183,9 @@ new Honk({
|
|
|
155
183
|
`duplicate: true`, so a lost response never creates a second message.
|
|
156
184
|
- Only network errors, timeouts, `429` and `5xx` are retried, with exponential backoff and
|
|
157
185
|
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
|
|
159
|
-
that resets at midnight), the error is thrown at once
|
|
186
|
+
- Everything stops at `deadlineMs`: if the next wait, plus time for one more attempt, would
|
|
187
|
+
cross it (for example a daily quota that resets at midnight), the error is thrown at once
|
|
188
|
+
with `retryAfter`.
|
|
160
189
|
- `4xx` other than `429` are never retried: fix the request instead.
|
|
161
190
|
- Short per-attempt timeouts; Node's `fetch` keeps connections alive, so create one `Honk`
|
|
162
191
|
per process and reuse it.
|
|
@@ -216,9 +245,11 @@ workflow publishes to npm with provenance (see `CHANGELOG.md`).
|
|
|
216
245
|
|
|
217
246
|
## Links
|
|
218
247
|
|
|
219
|
-
- [honk-me.app](https://honk-me.app): the Honk inbox (web, iPhone).
|
|
248
|
+
- [honk-me.app](https://honk-me.app): the Honk inbox (web, iPhone, Apple Watch).
|
|
220
249
|
- Other SDKs: [PHP / Laravel](https://github.com/honk-me/honk-php),
|
|
221
250
|
[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).
|
|
251
|
+
[Kotlin / Java](https://github.com/honk-me/honk-kotlin), [Rust](https://github.com/honk-me/honk-rust).
|
|
252
|
+
- No code: [n8n node](https://github.com/honk-me/honk-n8n) (`n8n-nodes-honk`) and the
|
|
253
|
+
[WordPress plugin](https://github.com/honk-me/honk-wordpress).
|
|
223
254
|
|
|
224
255
|
MIT License.
|
package/dist/cjs/client.js
CHANGED
|
@@ -12,6 +12,9 @@ const DEFAULT_RETRIES = 4;
|
|
|
12
12
|
const DEFAULT_DEADLINE_MS = 30_000;
|
|
13
13
|
const DEFAULT_BACKOFF_BASE_MS = 500;
|
|
14
14
|
const DEFAULT_BACKOFF_MAX_MS = 8_000;
|
|
15
|
+
// A retry starts only with at least this long left before the deadline (or timeoutMs, when
|
|
16
|
+
// shorter): with less it could only time out, and its timeout would hide the real error.
|
|
17
|
+
const MIN_ATTEMPT_MS = 250;
|
|
15
18
|
/** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
|
|
16
19
|
function parseRetryAfter(value, now = Date.now()) {
|
|
17
20
|
if (value === null)
|
|
@@ -120,7 +123,7 @@ class Honk {
|
|
|
120
123
|
throw failure;
|
|
121
124
|
const jitter = Math.random() * Math.min(this.#backoffMax, this.#backoffBase * 2 ** (attempt - 1));
|
|
122
125
|
const wait = Math.max(jitter, (failure.retryAfter ?? 0) * 1000);
|
|
123
|
-
if (Date.now() + wait
|
|
126
|
+
if (Date.now() + wait + Math.min(this.timeoutMs, MIN_ATTEMPT_MS) > deadline)
|
|
124
127
|
throw failure;
|
|
125
128
|
await sleep(wait, signal);
|
|
126
129
|
}
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { Honk, VERSION, parseRetryAfter } from './client.js';
|
|
|
2
2
|
export { HonkError, HonkValidationError, HonkAuthError, HonkQuotaError, HonkConflictError, HonkNetworkError, HonkTimeoutError, HonkServerError, type ErrorKind, type FieldError, } from './errors.js';
|
|
3
3
|
export { buildBody, LIMITS } from './validate.js';
|
|
4
4
|
export { uuidv7 } from './uuid.js';
|
|
5
|
-
export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
|
|
5
|
+
export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Action, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -41,6 +41,20 @@ export declare const PRIORITIES: readonly Priority[];
|
|
|
41
41
|
export declare const EVENT_TYPES: readonly EventType[];
|
|
42
42
|
export declare const CATEGORIES: readonly Category[];
|
|
43
43
|
export type MetadataValue = string | number | boolean;
|
|
44
|
+
/**
|
|
45
|
+
* A button on the message. Honk never opens or fetches the URL; the app opens it when the user
|
|
46
|
+
* taps the button.
|
|
47
|
+
*/
|
|
48
|
+
export interface Action {
|
|
49
|
+
/** 1–40 characters (trimmed), one line, shown as sent: `Reply`, `Call Emily`. */
|
|
50
|
+
title: string;
|
|
51
|
+
/**
|
|
52
|
+
* ≤ 2048 bytes, no spaces: `https://` (no credentials), `mailto:` with one address
|
|
53
|
+
* (`?subject=…&body=…` percent-encoded, no other keys), `tel:` or `sms:` with a number
|
|
54
|
+
* (`sms:` also `?body=…`). Other schemes are refused.
|
|
55
|
+
*/
|
|
56
|
+
url: string;
|
|
57
|
+
}
|
|
44
58
|
/**
|
|
45
59
|
* One event for `POST /v1/messages`. Only `message` is required. Fields are camelCase here and
|
|
46
60
|
* sent as the snake_case names of the API (`groupKey` → `group_key`). Empty optional strings
|
|
@@ -79,6 +93,8 @@ export interface Message {
|
|
|
79
93
|
url?: string | null;
|
|
80
94
|
/** HTTPS image fetched by the server after ingestion. No credentials or fragment. ≤ 2048 bytes. */
|
|
81
95
|
imageUrl?: string | null;
|
|
96
|
+
/** Up to 3 buttons, in display order (the first is the primary). Empty means none. */
|
|
97
|
+
actions?: readonly Action[] | null;
|
|
82
98
|
/** ≤ 16 keys matching `[A-Za-z0-9_.-]{1,64}`; values are strings (≤ 512 chars), numbers or booleans. */
|
|
83
99
|
metadata?: Record<string, MetadataValue> | null;
|
|
84
100
|
/** Push lifetime, 60–86400 seconds. Default 3600. */
|
package/dist/cjs/validate.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const LIMITS: {
|
|
|
9
9
|
readonly channel: 64;
|
|
10
10
|
readonly groupKey: 128;
|
|
11
11
|
readonly urlBytes: 2048;
|
|
12
|
+
readonly actions: 3;
|
|
13
|
+
readonly actionTitle: 40;
|
|
12
14
|
readonly metadataKeys: 16;
|
|
13
15
|
readonly metadataValue: 512;
|
|
14
16
|
readonly ttlMin: 60;
|
|
@@ -24,5 +26,11 @@ export declare function checkIdempotencyKey(key: unknown): string;
|
|
|
24
26
|
* listing every invalid field.
|
|
25
27
|
*/
|
|
26
28
|
export declare function buildBody(input: Message, defaults?: Defaults, validate?: boolean): Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
|
|
31
|
+
* no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
|
|
32
|
+
* `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function validActionUrl(s: string): boolean;
|
|
27
35
|
/** Syntactic check matching the server: https, a host, no credentials, no spaces/backslashes, ≤ 2048 bytes. */
|
|
28
36
|
export declare function validUrl(raw: unknown, image: boolean): boolean;
|
package/dist/cjs/validate.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.byteLength = exports.LIMITS = void 0;
|
|
4
4
|
exports.checkIdempotencyKey = checkIdempotencyKey;
|
|
5
5
|
exports.buildBody = buildBody;
|
|
6
|
+
exports.validActionUrl = validActionUrl;
|
|
6
7
|
exports.validUrl = validUrl;
|
|
7
8
|
const errors_js_1 = require("./errors.js");
|
|
8
9
|
const types_js_1 = require("./types.js");
|
|
@@ -16,6 +17,8 @@ exports.LIMITS = {
|
|
|
16
17
|
channel: 64,
|
|
17
18
|
groupKey: 128,
|
|
18
19
|
urlBytes: 2048,
|
|
20
|
+
actions: 3,
|
|
21
|
+
actionTitle: 40,
|
|
19
22
|
metadataKeys: 16,
|
|
20
23
|
metadataValue: 512,
|
|
21
24
|
ttlMin: 60,
|
|
@@ -37,6 +40,7 @@ const FIELDS = {
|
|
|
37
40
|
occurredAt: 'occurred_at',
|
|
38
41
|
url: 'url',
|
|
39
42
|
imageUrl: 'image_url',
|
|
43
|
+
actions: 'actions',
|
|
40
44
|
metadata: 'metadata',
|
|
41
45
|
ttlSeconds: 'ttl_seconds',
|
|
42
46
|
sourceSequence: 'source_sequence',
|
|
@@ -48,6 +52,15 @@ const CONTROL_EXCEPT_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0
|
|
|
48
52
|
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
49
53
|
const METADATA_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
50
54
|
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,128}$/;
|
|
55
|
+
// Unicode White_Space outside the control characters (Go's unicode.IsSpace, as on the server).
|
|
56
|
+
const SPACE = /[ \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
|
|
57
|
+
// The number of a tel: or sms: action: an optional leading +, digits and - . ( ) separators.
|
|
58
|
+
const PHONE_NUMBER = /^\+?[0-9().-]*[0-9][0-9().-]*$/;
|
|
59
|
+
// A single plain mailto: address, once percent-decoded: dot-atom@dot-atom with a dotted domain
|
|
60
|
+
// (or an IPv4 literal).
|
|
61
|
+
const ATOM = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~\\-\\u{80}-\\u{10ffff}]+";
|
|
62
|
+
const MAIL_ADDRESS = new RegExp(`^${ATOM}(\\.${ATOM})*@(${ATOM}(\\.${ATOM})+|\\[[0-9.]*\\.[0-9.]*\\])$`, 'u');
|
|
63
|
+
const BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
|
|
51
64
|
const encoder = new TextEncoder();
|
|
52
65
|
const byteLength = (s) => encoder.encode(s).length;
|
|
53
66
|
exports.byteLength = byteLength;
|
|
@@ -100,7 +113,7 @@ function buildBody(input, defaults = {}, validate = true) {
|
|
|
100
113
|
const body = {};
|
|
101
114
|
for (const name of Object.keys(FIELDS)) {
|
|
102
115
|
const v = value(name);
|
|
103
|
-
if (v === undefined)
|
|
116
|
+
if (v === undefined || (name === 'actions' && Array.isArray(v) && v.length === 0))
|
|
104
117
|
continue;
|
|
105
118
|
const wire = FIELDS[name];
|
|
106
119
|
if (name === 'occurredAt') {
|
|
@@ -177,6 +190,7 @@ function checkMessage(b, add) {
|
|
|
177
190
|
if (b.image_url !== undefined && !validUrl(b.image_url, true)) {
|
|
178
191
|
add('image_url', 'invalid_format', 'must be an https URL without credentials or fragment, at most 2048 bytes');
|
|
179
192
|
}
|
|
193
|
+
checkActions(b.actions, add);
|
|
180
194
|
const md = b.metadata;
|
|
181
195
|
if (md !== undefined) {
|
|
182
196
|
if (typeof md !== 'object' || md === null || Array.isArray(md)) {
|
|
@@ -210,6 +224,100 @@ function checkMessage(b, add) {
|
|
|
210
224
|
add('ttl_seconds', 'out_of_range', `must be an integer between ${exports.LIMITS.ttlMin} and ${exports.LIMITS.ttlMax}`);
|
|
211
225
|
}
|
|
212
226
|
}
|
|
227
|
+
function checkActions(v, add) {
|
|
228
|
+
if (v === undefined)
|
|
229
|
+
return;
|
|
230
|
+
const format = 'must be an array of at most 3 { title, url } objects';
|
|
231
|
+
if (!Array.isArray(v))
|
|
232
|
+
return add('actions', 'invalid_format', format);
|
|
233
|
+
if (v.length > exports.LIMITS.actions)
|
|
234
|
+
return add('actions', 'too_long', `at most ${exports.LIMITS.actions} actions`);
|
|
235
|
+
if (v.some((a) => a === null || typeof a !== 'object' || Array.isArray(a)))
|
|
236
|
+
return add('actions', 'invalid_format', format);
|
|
237
|
+
v.forEach((a, i) => {
|
|
238
|
+
const field = `actions[${i}]`;
|
|
239
|
+
const title = typeof a.title === 'string' ? a.title.trim() : a.title;
|
|
240
|
+
if (title === undefined || title === null || title === '')
|
|
241
|
+
add(`${field}.title`, 'required', 'title is required');
|
|
242
|
+
else if (typeof title !== 'string')
|
|
243
|
+
add(`${field}.title`, 'invalid_format', 'must be a string');
|
|
244
|
+
else if (codePoints(title) > exports.LIMITS.actionTitle)
|
|
245
|
+
add(`${field}.title`, 'too_long', `must be at most ${exports.LIMITS.actionTitle} characters`);
|
|
246
|
+
else if (CONTROL.test(title))
|
|
247
|
+
add(`${field}.title`, 'invalid_format', 'must be one line without control characters');
|
|
248
|
+
const url = typeof a.url === 'string' ? a.url.trim() : a.url;
|
|
249
|
+
if (url === undefined || url === null || url === '')
|
|
250
|
+
add(`${field}.url`, 'required', 'url is required');
|
|
251
|
+
else if (typeof url !== 'string')
|
|
252
|
+
add(`${field}.url`, 'invalid_format', 'must be a string');
|
|
253
|
+
else if ((0, exports.byteLength)(url) > exports.LIMITS.urlBytes)
|
|
254
|
+
add(`${field}.url`, 'too_long', `must be at most ${exports.LIMITS.urlBytes} bytes`);
|
|
255
|
+
else if (!validActionUrl(url))
|
|
256
|
+
add(`${field}.url`, 'invalid_format', 'must be an https://, mailto:, tel: or sms: URL without spaces');
|
|
257
|
+
for (const key of Object.keys(a).sort()) {
|
|
258
|
+
if (key !== 'title' && key !== 'url')
|
|
259
|
+
add(`${field}.${key}`, 'not_allowed', 'unknown field (an action has title and url)');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
|
|
265
|
+
* no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
|
|
266
|
+
* `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
|
|
267
|
+
*/
|
|
268
|
+
function validActionUrl(s) {
|
|
269
|
+
if (CONTROL.test(s) || SPACE.test(s))
|
|
270
|
+
return false;
|
|
271
|
+
const colon = s.indexOf(':');
|
|
272
|
+
if (colon < 0)
|
|
273
|
+
return false;
|
|
274
|
+
const rest = s.slice(colon + 1);
|
|
275
|
+
const [head, query] = splitOnce(rest, '?');
|
|
276
|
+
switch (s.slice(0, colon).toLowerCase()) {
|
|
277
|
+
case 'https':
|
|
278
|
+
return validUrl(s, false);
|
|
279
|
+
case 'mailto':
|
|
280
|
+
return mailAddress(head) && actionQuery(query, ['subject', 'body']);
|
|
281
|
+
case 'tel':
|
|
282
|
+
return PHONE_NUMBER.test(rest.startsWith('//') ? rest.slice(2) : rest);
|
|
283
|
+
case 'sms':
|
|
284
|
+
return PHONE_NUMBER.test(head) && actionQuery(query, ['body']);
|
|
285
|
+
default:
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function splitOnce(s, sep) {
|
|
290
|
+
const i = s.indexOf(sep);
|
|
291
|
+
return i < 0 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)];
|
|
292
|
+
}
|
|
293
|
+
function mailAddress(s) {
|
|
294
|
+
if (BAD_ESCAPE.test(s))
|
|
295
|
+
return false;
|
|
296
|
+
try {
|
|
297
|
+
return MAIL_ADDRESS.test(decodeURIComponent(s));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Valid percent-encoding, no ";" separators and only the allowed keys ("" is no query).
|
|
304
|
+
function actionQuery(q, allowed) {
|
|
305
|
+
for (const pair of q.split('&')) {
|
|
306
|
+
if (pair.includes(';') || BAD_ESCAPE.test(pair))
|
|
307
|
+
return false;
|
|
308
|
+
if (pair === '')
|
|
309
|
+
continue;
|
|
310
|
+
const [key] = splitOnce(pair, '=');
|
|
311
|
+
try {
|
|
312
|
+
if (!allowed.includes(decodeURIComponent(key.replace(/\+/g, ' '))))
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
213
321
|
function shortText(b, field, max, add) {
|
|
214
322
|
const v = b[field];
|
|
215
323
|
if (v === undefined)
|
package/dist/cjs/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
package/dist/cjs/version.js
CHANGED
|
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VERSION = void 0;
|
|
4
4
|
// Generated from package.json by scripts/build.mjs on every build. Do not edit: change
|
|
5
5
|
// "version" in package.json instead.
|
|
6
|
-
exports.VERSION = '0.
|
|
6
|
+
exports.VERSION = '0.2.0';
|
package/dist/esm/client.js
CHANGED
|
@@ -8,6 +8,9 @@ const DEFAULT_RETRIES = 4;
|
|
|
8
8
|
const DEFAULT_DEADLINE_MS = 30_000;
|
|
9
9
|
const DEFAULT_BACKOFF_BASE_MS = 500;
|
|
10
10
|
const DEFAULT_BACKOFF_MAX_MS = 8_000;
|
|
11
|
+
// A retry starts only with at least this long left before the deadline (or timeoutMs, when
|
|
12
|
+
// shorter): with less it could only time out, and its timeout would hide the real error.
|
|
13
|
+
const MIN_ATTEMPT_MS = 250;
|
|
11
14
|
/** Seconds from a Retry-After header (delta-seconds or HTTP date), or undefined. */
|
|
12
15
|
export function parseRetryAfter(value, now = Date.now()) {
|
|
13
16
|
if (value === null)
|
|
@@ -116,7 +119,7 @@ export class Honk {
|
|
|
116
119
|
throw failure;
|
|
117
120
|
const jitter = Math.random() * Math.min(this.#backoffMax, this.#backoffBase * 2 ** (attempt - 1));
|
|
118
121
|
const wait = Math.max(jitter, (failure.retryAfter ?? 0) * 1000);
|
|
119
|
-
if (Date.now() + wait
|
|
122
|
+
if (Date.now() + wait + Math.min(this.timeoutMs, MIN_ATTEMPT_MS) > deadline)
|
|
120
123
|
throw failure;
|
|
121
124
|
await sleep(wait, signal);
|
|
122
125
|
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { Honk, VERSION, parseRetryAfter } from './client.js';
|
|
|
2
2
|
export { HonkError, HonkValidationError, HonkAuthError, HonkQuotaError, HonkConflictError, HonkNetworkError, HonkTimeoutError, HonkServerError, type ErrorKind, type FieldError, } from './errors.js';
|
|
3
3
|
export { buildBody, LIMITS } from './validate.js';
|
|
4
4
|
export { uuidv7 } from './uuid.js';
|
|
5
|
-
export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
|
|
5
|
+
export { Severity, SEVERITY_ALIASES, normalizeSeverity, SEVERITIES, PRIORITIES, EVENT_TYPES, CATEGORIES, type SeverityAlias, type SeverityInput, type Priority, type EventType, type Category, type MetadataValue, type Action, type Message, type Defaults, type HonkOptions, type SendOptions, type HelperOptions, type SendResult, } from './types.js';
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -41,6 +41,20 @@ export declare const PRIORITIES: readonly Priority[];
|
|
|
41
41
|
export declare const EVENT_TYPES: readonly EventType[];
|
|
42
42
|
export declare const CATEGORIES: readonly Category[];
|
|
43
43
|
export type MetadataValue = string | number | boolean;
|
|
44
|
+
/**
|
|
45
|
+
* A button on the message. Honk never opens or fetches the URL; the app opens it when the user
|
|
46
|
+
* taps the button.
|
|
47
|
+
*/
|
|
48
|
+
export interface Action {
|
|
49
|
+
/** 1–40 characters (trimmed), one line, shown as sent: `Reply`, `Call Emily`. */
|
|
50
|
+
title: string;
|
|
51
|
+
/**
|
|
52
|
+
* ≤ 2048 bytes, no spaces: `https://` (no credentials), `mailto:` with one address
|
|
53
|
+
* (`?subject=…&body=…` percent-encoded, no other keys), `tel:` or `sms:` with a number
|
|
54
|
+
* (`sms:` also `?body=…`). Other schemes are refused.
|
|
55
|
+
*/
|
|
56
|
+
url: string;
|
|
57
|
+
}
|
|
44
58
|
/**
|
|
45
59
|
* One event for `POST /v1/messages`. Only `message` is required. Fields are camelCase here and
|
|
46
60
|
* sent as the snake_case names of the API (`groupKey` → `group_key`). Empty optional strings
|
|
@@ -79,6 +93,8 @@ export interface Message {
|
|
|
79
93
|
url?: string | null;
|
|
80
94
|
/** HTTPS image fetched by the server after ingestion. No credentials or fragment. ≤ 2048 bytes. */
|
|
81
95
|
imageUrl?: string | null;
|
|
96
|
+
/** Up to 3 buttons, in display order (the first is the primary). Empty means none. */
|
|
97
|
+
actions?: readonly Action[] | null;
|
|
82
98
|
/** ≤ 16 keys matching `[A-Za-z0-9_.-]{1,64}`; values are strings (≤ 512 chars), numbers or booleans. */
|
|
83
99
|
metadata?: Record<string, MetadataValue> | null;
|
|
84
100
|
/** Push lifetime, 60–86400 seconds. Default 3600. */
|
package/dist/esm/validate.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const LIMITS: {
|
|
|
9
9
|
readonly channel: 64;
|
|
10
10
|
readonly groupKey: 128;
|
|
11
11
|
readonly urlBytes: 2048;
|
|
12
|
+
readonly actions: 3;
|
|
13
|
+
readonly actionTitle: 40;
|
|
12
14
|
readonly metadataKeys: 16;
|
|
13
15
|
readonly metadataValue: 512;
|
|
14
16
|
readonly ttlMin: 60;
|
|
@@ -24,5 +26,11 @@ export declare function checkIdempotencyKey(key: unknown): string;
|
|
|
24
26
|
* listing every invalid field.
|
|
25
27
|
*/
|
|
26
28
|
export declare function buildBody(input: Message, defaults?: Defaults, validate?: boolean): Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
|
|
31
|
+
* no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
|
|
32
|
+
* `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function validActionUrl(s: string): boolean;
|
|
27
35
|
/** Syntactic check matching the server: https, a host, no credentials, no spaces/backslashes, ≤ 2048 bytes. */
|
|
28
36
|
export declare function validUrl(raw: unknown, image: boolean): boolean;
|
package/dist/esm/validate.js
CHANGED
|
@@ -10,6 +10,8 @@ export const LIMITS = {
|
|
|
10
10
|
channel: 64,
|
|
11
11
|
groupKey: 128,
|
|
12
12
|
urlBytes: 2048,
|
|
13
|
+
actions: 3,
|
|
14
|
+
actionTitle: 40,
|
|
13
15
|
metadataKeys: 16,
|
|
14
16
|
metadataValue: 512,
|
|
15
17
|
ttlMin: 60,
|
|
@@ -31,6 +33,7 @@ const FIELDS = {
|
|
|
31
33
|
occurredAt: 'occurred_at',
|
|
32
34
|
url: 'url',
|
|
33
35
|
imageUrl: 'image_url',
|
|
36
|
+
actions: 'actions',
|
|
34
37
|
metadata: 'metadata',
|
|
35
38
|
ttlSeconds: 'ttl_seconds',
|
|
36
39
|
sourceSequence: 'source_sequence',
|
|
@@ -42,6 +45,15 @@ const CONTROL_EXCEPT_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0
|
|
|
42
45
|
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
43
46
|
const METADATA_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
44
47
|
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,128}$/;
|
|
48
|
+
// Unicode White_Space outside the control characters (Go's unicode.IsSpace, as on the server).
|
|
49
|
+
const SPACE = /[ \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
|
|
50
|
+
// The number of a tel: or sms: action: an optional leading +, digits and - . ( ) separators.
|
|
51
|
+
const PHONE_NUMBER = /^\+?[0-9().-]*[0-9][0-9().-]*$/;
|
|
52
|
+
// A single plain mailto: address, once percent-decoded: dot-atom@dot-atom with a dotted domain
|
|
53
|
+
// (or an IPv4 literal).
|
|
54
|
+
const ATOM = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~\\-\\u{80}-\\u{10ffff}]+";
|
|
55
|
+
const MAIL_ADDRESS = new RegExp(`^${ATOM}(\\.${ATOM})*@(${ATOM}(\\.${ATOM})+|\\[[0-9.]*\\.[0-9.]*\\])$`, 'u');
|
|
56
|
+
const BAD_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
|
|
45
57
|
const encoder = new TextEncoder();
|
|
46
58
|
export const byteLength = (s) => encoder.encode(s).length;
|
|
47
59
|
const codePoints = (s) => {
|
|
@@ -93,7 +105,7 @@ export function buildBody(input, defaults = {}, validate = true) {
|
|
|
93
105
|
const body = {};
|
|
94
106
|
for (const name of Object.keys(FIELDS)) {
|
|
95
107
|
const v = value(name);
|
|
96
|
-
if (v === undefined)
|
|
108
|
+
if (v === undefined || (name === 'actions' && Array.isArray(v) && v.length === 0))
|
|
97
109
|
continue;
|
|
98
110
|
const wire = FIELDS[name];
|
|
99
111
|
if (name === 'occurredAt') {
|
|
@@ -170,6 +182,7 @@ function checkMessage(b, add) {
|
|
|
170
182
|
if (b.image_url !== undefined && !validUrl(b.image_url, true)) {
|
|
171
183
|
add('image_url', 'invalid_format', 'must be an https URL without credentials or fragment, at most 2048 bytes');
|
|
172
184
|
}
|
|
185
|
+
checkActions(b.actions, add);
|
|
173
186
|
const md = b.metadata;
|
|
174
187
|
if (md !== undefined) {
|
|
175
188
|
if (typeof md !== 'object' || md === null || Array.isArray(md)) {
|
|
@@ -203,6 +216,100 @@ function checkMessage(b, add) {
|
|
|
203
216
|
add('ttl_seconds', 'out_of_range', `must be an integer between ${LIMITS.ttlMin} and ${LIMITS.ttlMax}`);
|
|
204
217
|
}
|
|
205
218
|
}
|
|
219
|
+
function checkActions(v, add) {
|
|
220
|
+
if (v === undefined)
|
|
221
|
+
return;
|
|
222
|
+
const format = 'must be an array of at most 3 { title, url } objects';
|
|
223
|
+
if (!Array.isArray(v))
|
|
224
|
+
return add('actions', 'invalid_format', format);
|
|
225
|
+
if (v.length > LIMITS.actions)
|
|
226
|
+
return add('actions', 'too_long', `at most ${LIMITS.actions} actions`);
|
|
227
|
+
if (v.some((a) => a === null || typeof a !== 'object' || Array.isArray(a)))
|
|
228
|
+
return add('actions', 'invalid_format', format);
|
|
229
|
+
v.forEach((a, i) => {
|
|
230
|
+
const field = `actions[${i}]`;
|
|
231
|
+
const title = typeof a.title === 'string' ? a.title.trim() : a.title;
|
|
232
|
+
if (title === undefined || title === null || title === '')
|
|
233
|
+
add(`${field}.title`, 'required', 'title is required');
|
|
234
|
+
else if (typeof title !== 'string')
|
|
235
|
+
add(`${field}.title`, 'invalid_format', 'must be a string');
|
|
236
|
+
else if (codePoints(title) > LIMITS.actionTitle)
|
|
237
|
+
add(`${field}.title`, 'too_long', `must be at most ${LIMITS.actionTitle} characters`);
|
|
238
|
+
else if (CONTROL.test(title))
|
|
239
|
+
add(`${field}.title`, 'invalid_format', 'must be one line without control characters');
|
|
240
|
+
const url = typeof a.url === 'string' ? a.url.trim() : a.url;
|
|
241
|
+
if (url === undefined || url === null || url === '')
|
|
242
|
+
add(`${field}.url`, 'required', 'url is required');
|
|
243
|
+
else if (typeof url !== 'string')
|
|
244
|
+
add(`${field}.url`, 'invalid_format', 'must be a string');
|
|
245
|
+
else if (byteLength(url) > LIMITS.urlBytes)
|
|
246
|
+
add(`${field}.url`, 'too_long', `must be at most ${LIMITS.urlBytes} bytes`);
|
|
247
|
+
else if (!validActionUrl(url))
|
|
248
|
+
add(`${field}.url`, 'invalid_format', 'must be an https://, mailto:, tel: or sms: URL without spaces');
|
|
249
|
+
for (const key of Object.keys(a).sort()) {
|
|
250
|
+
if (key !== 'title' && key !== 'url')
|
|
251
|
+
add(`${field}.${key}`, 'not_allowed', 'unknown field (an action has title and url)');
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The server's check of a (trimmed) action URL, scheme in any case: `https://` with a host and
|
|
257
|
+
* no credentials (as `url`), `mailto:` with one address and an optional `?subject=…&body=…`,
|
|
258
|
+
* `tel:` / `tel://` with a number, `sms:` with a number and an optional `?body=…`.
|
|
259
|
+
*/
|
|
260
|
+
export function validActionUrl(s) {
|
|
261
|
+
if (CONTROL.test(s) || SPACE.test(s))
|
|
262
|
+
return false;
|
|
263
|
+
const colon = s.indexOf(':');
|
|
264
|
+
if (colon < 0)
|
|
265
|
+
return false;
|
|
266
|
+
const rest = s.slice(colon + 1);
|
|
267
|
+
const [head, query] = splitOnce(rest, '?');
|
|
268
|
+
switch (s.slice(0, colon).toLowerCase()) {
|
|
269
|
+
case 'https':
|
|
270
|
+
return validUrl(s, false);
|
|
271
|
+
case 'mailto':
|
|
272
|
+
return mailAddress(head) && actionQuery(query, ['subject', 'body']);
|
|
273
|
+
case 'tel':
|
|
274
|
+
return PHONE_NUMBER.test(rest.startsWith('//') ? rest.slice(2) : rest);
|
|
275
|
+
case 'sms':
|
|
276
|
+
return PHONE_NUMBER.test(head) && actionQuery(query, ['body']);
|
|
277
|
+
default:
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function splitOnce(s, sep) {
|
|
282
|
+
const i = s.indexOf(sep);
|
|
283
|
+
return i < 0 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)];
|
|
284
|
+
}
|
|
285
|
+
function mailAddress(s) {
|
|
286
|
+
if (BAD_ESCAPE.test(s))
|
|
287
|
+
return false;
|
|
288
|
+
try {
|
|
289
|
+
return MAIL_ADDRESS.test(decodeURIComponent(s));
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Valid percent-encoding, no ";" separators and only the allowed keys ("" is no query).
|
|
296
|
+
function actionQuery(q, allowed) {
|
|
297
|
+
for (const pair of q.split('&')) {
|
|
298
|
+
if (pair.includes(';') || BAD_ESCAPE.test(pair))
|
|
299
|
+
return false;
|
|
300
|
+
if (pair === '')
|
|
301
|
+
continue;
|
|
302
|
+
const [key] = splitOnce(pair, '=');
|
|
303
|
+
try {
|
|
304
|
+
if (!allowed.includes(decodeURIComponent(key.replace(/\+/g, ' '))))
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
206
313
|
function shortText(b, field, max, add) {
|
|
207
314
|
const v = b[field];
|
|
208
315
|
if (v === undefined)
|
package/dist/esm/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
package/dist/esm/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "honk-me",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Official Node.js/TypeScript client for Honk (honk-me.app): send events from apps, scripts and automations to your phone, with retries and idempotency built in.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"honk",
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
"url": "git+https://github.com/honk-me/honk-node.git"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
27
|
-
"author":
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Honk",
|
|
29
|
+
"email": "accounts@honk-me.app",
|
|
30
|
+
"url": "https://honk-me.app"
|
|
31
|
+
},
|
|
28
32
|
"sideEffects": false,
|
|
29
33
|
"main": "./dist/cjs/index.js",
|
|
30
34
|
"module": "./dist/esm/index.js",
|