honk-me 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/cjs/client.d.ts +58 -0
- package/dist/cjs/client.js +334 -0
- package/dist/cjs/errors.d.ts +84 -0
- package/dist/cjs/errors.js +108 -0
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/types.d.ts +138 -0
- package/dist/cjs/types.js +53 -0
- package/dist/cjs/uuid.d.ts +5 -0
- package/dist/cjs/uuid.js +40 -0
- package/dist/cjs/validate.d.ts +28 -0
- package/dist/cjs/validate.js +272 -0
- package/dist/cjs/version.d.ts +1 -0
- package/dist/cjs/version.js +6 -0
- package/dist/esm/client.d.ts +58 -0
- package/dist/esm/client.js +329 -0
- package/dist/esm/errors.d.ts +84 -0
- package/dist/esm/errors.js +97 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/types.d.ts +138 -0
- package/dist/esm/types.js +49 -0
- package/dist/esm/uuid.d.ts +5 -0
- package/dist/esm/uuid.js +37 -0
- package/dist/esm/validate.d.ts +28 -0
- package/dist/esm/validate.js +265 -0
- package/dist/esm/version.d.ts +1 -0
- package/dist/esm/version.js +3 -0
- package/package.json +68 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { HonkValidationError } from './errors.js';
|
|
2
|
+
import { CATEGORIES, EVENT_TYPES, PRIORITIES, SEVERITIES, SEVERITY_ALIASES, normalizeSeverity } from './types.js';
|
|
3
|
+
/** Limits from contracts/openapi.yaml (and the server's validator). */
|
|
4
|
+
export const LIMITS = {
|
|
5
|
+
bodyBytes: 16 * 1024,
|
|
6
|
+
messageBytes: 8192,
|
|
7
|
+
title: 160,
|
|
8
|
+
source: 64,
|
|
9
|
+
environment: 32,
|
|
10
|
+
channel: 64,
|
|
11
|
+
groupKey: 128,
|
|
12
|
+
urlBytes: 2048,
|
|
13
|
+
metadataKeys: 16,
|
|
14
|
+
metadataValue: 512,
|
|
15
|
+
ttlMin: 60,
|
|
16
|
+
ttlMax: 86400,
|
|
17
|
+
idempotencyKey: 128,
|
|
18
|
+
};
|
|
19
|
+
// camelCase SDK field → snake_case wire field, in a stable order.
|
|
20
|
+
const FIELDS = {
|
|
21
|
+
title: 'title',
|
|
22
|
+
message: 'message',
|
|
23
|
+
severity: 'severity',
|
|
24
|
+
priority: 'priority',
|
|
25
|
+
category: 'category',
|
|
26
|
+
source: 'source',
|
|
27
|
+
environment: 'environment',
|
|
28
|
+
channel: 'channel',
|
|
29
|
+
groupKey: 'group_key',
|
|
30
|
+
eventType: 'event_type',
|
|
31
|
+
occurredAt: 'occurred_at',
|
|
32
|
+
url: 'url',
|
|
33
|
+
imageUrl: 'image_url',
|
|
34
|
+
metadata: 'metadata',
|
|
35
|
+
ttlSeconds: 'ttl_seconds',
|
|
36
|
+
sourceSequence: 'source_sequence',
|
|
37
|
+
};
|
|
38
|
+
const WIRE_TO_FIELD = Object.fromEntries(Object.entries(FIELDS).map(([k, v]) => [v, k]));
|
|
39
|
+
// Go's unicode.IsControl (C0, DEL, C1) plus the Unicode line/paragraph separators, as on the server.
|
|
40
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
41
|
+
const CONTROL_EXCEPT_BREAKS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
42
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
43
|
+
const METADATA_KEY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
44
|
+
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,128}$/;
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
export const byteLength = (s) => encoder.encode(s).length;
|
|
47
|
+
const codePoints = (s) => {
|
|
48
|
+
let n = 0;
|
|
49
|
+
for (const _ of s)
|
|
50
|
+
n++;
|
|
51
|
+
return n;
|
|
52
|
+
};
|
|
53
|
+
const isBlank = (v) => v === undefined || v === null || v === '';
|
|
54
|
+
/** Checks an Idempotency-Key: 1–128 printable ASCII characters (0x21–0x7E). */
|
|
55
|
+
export function checkIdempotencyKey(key) {
|
|
56
|
+
if (typeof key !== 'string' || !IDEMPOTENCY_KEY.test(key)) {
|
|
57
|
+
throw new HonkValidationError('Invalid idempotency key: use 1–128 printable ASCII characters without spaces, e.g. "request-4812"', { local: true, code: 'validation_failed', fields: [{ field: 'Idempotency-Key', code: 'invalid_format', message: '1-128 printable ASCII characters' }] });
|
|
58
|
+
}
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Turns a message into the wire object of `POST /v1/messages`, applying `defaults` and (unless
|
|
63
|
+
* `validate` is false) the cheap checks the server would apply anyway. Throws HonkValidationError
|
|
64
|
+
* listing every invalid field.
|
|
65
|
+
*/
|
|
66
|
+
export function buildBody(input, defaults = {}, validate = true) {
|
|
67
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
68
|
+
throw new HonkValidationError('Invalid Honk message: expected an object like { title, message }', {
|
|
69
|
+
local: true,
|
|
70
|
+
code: 'validation_failed',
|
|
71
|
+
fields: [{ field: 'body', code: 'invalid_format', message: 'must be an object' }],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const errors = [];
|
|
75
|
+
const add = (field, code, message) => errors.push({ field, code, message });
|
|
76
|
+
const msg = input;
|
|
77
|
+
for (const key of Object.keys(msg)) {
|
|
78
|
+
if (!(key in FIELDS)) {
|
|
79
|
+
const hint = WIRE_TO_FIELD[key] ? ` (use ${WIRE_TO_FIELD[key]})` : '';
|
|
80
|
+
add(key, 'not_allowed', `unknown field${hint}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const value = (name) => {
|
|
84
|
+
const v = msg[name];
|
|
85
|
+
if (!isBlank(v))
|
|
86
|
+
return v;
|
|
87
|
+
if (name === 'source' || name === 'environment' || name === 'channel') {
|
|
88
|
+
const d = defaults[name];
|
|
89
|
+
return isBlank(d) ? undefined : d;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
const body = {};
|
|
94
|
+
for (const name of Object.keys(FIELDS)) {
|
|
95
|
+
const v = value(name);
|
|
96
|
+
if (v === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
const wire = FIELDS[name];
|
|
99
|
+
if (name === 'occurredAt') {
|
|
100
|
+
if (v instanceof Date) {
|
|
101
|
+
if (Number.isNaN(v.getTime()))
|
|
102
|
+
add(wire, 'invalid_format', 'must be a valid date');
|
|
103
|
+
else
|
|
104
|
+
body[wire] = v.toISOString();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (validate && (typeof v !== 'string' || !RFC3339.test(v.trim()))) {
|
|
108
|
+
add(wire, 'invalid_format', 'must be a Date or an RFC 3339 timestamp like 2026-10-01T21:10:00Z');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Horn aliases (and any case) become the canonical value, even with validate: false, so
|
|
113
|
+
// the idempotency payload is canonical and older servers understand it.
|
|
114
|
+
body[wire] = name === 'severity' && typeof v === 'string' ? (normalizeSeverity(v) ?? v) : v;
|
|
115
|
+
}
|
|
116
|
+
if (validate) {
|
|
117
|
+
checkMessage(body, add);
|
|
118
|
+
}
|
|
119
|
+
else if (msg.message === undefined || msg.message === null) {
|
|
120
|
+
add('message', 'required', 'message is required');
|
|
121
|
+
}
|
|
122
|
+
if (errors.length === 0) {
|
|
123
|
+
const size = byteLength(JSON.stringify(body));
|
|
124
|
+
if (size > LIMITS.bodyBytes)
|
|
125
|
+
add('body', 'too_long', `the JSON body is ${size} bytes; Honk accepts at most 16 KiB`);
|
|
126
|
+
}
|
|
127
|
+
if (errors.length > 0) {
|
|
128
|
+
const summary = errors.map((e) => `${e.field} ${e.message}`).join('; ');
|
|
129
|
+
throw new HonkValidationError(`Invalid Honk message: ${summary}`, { local: true, code: 'validation_failed', fields: errors });
|
|
130
|
+
}
|
|
131
|
+
return body;
|
|
132
|
+
}
|
|
133
|
+
function checkMessage(b, add) {
|
|
134
|
+
const m = b.message;
|
|
135
|
+
if (m === undefined)
|
|
136
|
+
add('message', 'required', 'message is required');
|
|
137
|
+
else if (typeof m !== 'string')
|
|
138
|
+
add('message', 'invalid_format', 'must be a string');
|
|
139
|
+
else if (byteLength(m) > LIMITS.messageBytes)
|
|
140
|
+
add('message', 'too_long', `must be at most ${LIMITS.messageBytes} bytes of UTF-8 (got ${byteLength(m)})`);
|
|
141
|
+
else if (m.trim() === '')
|
|
142
|
+
add('message', 'too_short', 'must not be blank');
|
|
143
|
+
else if (CONTROL_EXCEPT_BREAKS.test(m))
|
|
144
|
+
add('message', 'invalid_format', 'must not contain control characters other than line breaks and tabs');
|
|
145
|
+
shortText(b, 'title', LIMITS.title, add);
|
|
146
|
+
shortText(b, 'source', LIMITS.source, add);
|
|
147
|
+
shortText(b, 'environment', LIMITS.environment, add);
|
|
148
|
+
shortText(b, 'channel', LIMITS.channel, add);
|
|
149
|
+
shortText(b, 'group_key', LIMITS.groupKey, add);
|
|
150
|
+
if (b.severity !== undefined && (typeof b.severity !== 'string' || !SEVERITIES.includes(b.severity))) {
|
|
151
|
+
const aliases = Object.entries(SEVERITY_ALIASES).map(([alias, canonical]) => `${alias} (${canonical})`);
|
|
152
|
+
add('severity', 'invalid_enum', `must be one of ${aliases.join(', ')}`);
|
|
153
|
+
}
|
|
154
|
+
oneOf(b, 'priority', PRIORITIES, add);
|
|
155
|
+
oneOf(b, 'event_type', EVENT_TYPES, add);
|
|
156
|
+
oneOf(b, 'category', CATEGORIES, add);
|
|
157
|
+
const seq = b.source_sequence;
|
|
158
|
+
if (seq !== undefined && (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
|
|
159
|
+
add('source_sequence', 'out_of_range', 'must be an integer between 0 and 2^53-1');
|
|
160
|
+
}
|
|
161
|
+
if (b.group_key === undefined) {
|
|
162
|
+
if (b.event_type === 'recovery')
|
|
163
|
+
add('group_key', 'requires_group_key', 'recovery events require group_key');
|
|
164
|
+
if (seq !== undefined)
|
|
165
|
+
add('source_sequence', 'requires_group_key', 'source_sequence requires group_key');
|
|
166
|
+
}
|
|
167
|
+
if (b.url !== undefined && !validUrl(b.url, false)) {
|
|
168
|
+
add('url', 'invalid_format', 'must be an https URL without credentials, at most 2048 bytes');
|
|
169
|
+
}
|
|
170
|
+
if (b.image_url !== undefined && !validUrl(b.image_url, true)) {
|
|
171
|
+
add('image_url', 'invalid_format', 'must be an https URL without credentials or fragment, at most 2048 bytes');
|
|
172
|
+
}
|
|
173
|
+
const md = b.metadata;
|
|
174
|
+
if (md !== undefined) {
|
|
175
|
+
if (typeof md !== 'object' || md === null || Array.isArray(md)) {
|
|
176
|
+
add('metadata', 'invalid_format', 'must be an object of strings, numbers and booleans');
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const entries = Object.entries(md);
|
|
180
|
+
if (entries.length > LIMITS.metadataKeys)
|
|
181
|
+
add('metadata', 'too_long', `at most ${LIMITS.metadataKeys} keys`);
|
|
182
|
+
for (const [k, v] of entries) {
|
|
183
|
+
const field = `metadata.${k}`;
|
|
184
|
+
if (!METADATA_KEY.test(k))
|
|
185
|
+
add(field, 'invalid_format', 'keys must match [A-Za-z0-9_.-]{1,64}');
|
|
186
|
+
else if (typeof v === 'string') {
|
|
187
|
+
if (codePoints(v) > LIMITS.metadataValue || CONTROL_EXCEPT_BREAKS.test(v)) {
|
|
188
|
+
add(field, 'invalid_format', `strings must be at most ${LIMITS.metadataValue} characters without control characters`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (typeof v === 'number') {
|
|
192
|
+
if (!Number.isFinite(v))
|
|
193
|
+
add(field, 'invalid_format', 'numbers must be finite');
|
|
194
|
+
}
|
|
195
|
+
else if (typeof v !== 'boolean') {
|
|
196
|
+
add(field, 'invalid_format', 'values must be strings, numbers or booleans');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const ttl = b.ttl_seconds;
|
|
202
|
+
if (ttl !== undefined && (typeof ttl !== 'number' || !Number.isInteger(ttl) || ttl < LIMITS.ttlMin || ttl > LIMITS.ttlMax)) {
|
|
203
|
+
add('ttl_seconds', 'out_of_range', `must be an integer between ${LIMITS.ttlMin} and ${LIMITS.ttlMax}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function shortText(b, field, max, add) {
|
|
207
|
+
const v = b[field];
|
|
208
|
+
if (v === undefined)
|
|
209
|
+
return;
|
|
210
|
+
if (typeof v !== 'string')
|
|
211
|
+
return add(field, 'invalid_format', 'must be a string');
|
|
212
|
+
const s = v.trim();
|
|
213
|
+
if (s === '')
|
|
214
|
+
add(field, 'too_short', 'must not be empty');
|
|
215
|
+
else if (codePoints(s) > max)
|
|
216
|
+
add(field, 'too_long', `must be at most ${max} characters`);
|
|
217
|
+
else if (CONTROL.test(s))
|
|
218
|
+
add(field, 'invalid_format', 'must not contain control characters or line breaks');
|
|
219
|
+
}
|
|
220
|
+
function oneOf(b, field, allowed, add) {
|
|
221
|
+
const v = b[field];
|
|
222
|
+
if (v !== undefined && (typeof v !== 'string' || !allowed.includes(v))) {
|
|
223
|
+
add(field, 'invalid_enum', `must be one of ${allowed.join(', ')}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Syntactic check matching the server: https, a host, no credentials, no spaces/backslashes, ≤ 2048 bytes. */
|
|
227
|
+
export function validUrl(raw, image) {
|
|
228
|
+
if (typeof raw !== 'string')
|
|
229
|
+
return false;
|
|
230
|
+
const s = raw.trim();
|
|
231
|
+
if (s === '' || byteLength(s) > LIMITS.urlBytes || CONTROL.test(s) || /[ \\]/.test(s))
|
|
232
|
+
return false;
|
|
233
|
+
if (!/^https:\/\//i.test(s))
|
|
234
|
+
return false;
|
|
235
|
+
if (image && s.includes('#'))
|
|
236
|
+
return false;
|
|
237
|
+
const rest = s.slice('https://'.length);
|
|
238
|
+
const authority = rest.split(/[/?#]/, 1)[0] ?? '';
|
|
239
|
+
if (authority === '' || authority.includes('@'))
|
|
240
|
+
return false;
|
|
241
|
+
let host = authority;
|
|
242
|
+
let port = '';
|
|
243
|
+
if (authority.startsWith('[')) {
|
|
244
|
+
const end = authority.indexOf(']');
|
|
245
|
+
if (end < 0)
|
|
246
|
+
return false;
|
|
247
|
+
host = authority.slice(0, end + 1);
|
|
248
|
+
const after = authority.slice(end + 1);
|
|
249
|
+
if (after !== '' && !after.startsWith(':'))
|
|
250
|
+
return false;
|
|
251
|
+
port = after.slice(1);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const i = authority.lastIndexOf(':');
|
|
255
|
+
if (i >= 0) {
|
|
256
|
+
host = authority.slice(0, i);
|
|
257
|
+
port = authority.slice(i + 1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (host === '' || host === '[]')
|
|
261
|
+
return false;
|
|
262
|
+
if (port !== '' && (!/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535))
|
|
263
|
+
return false;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.1.0";
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "honk-me",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"honk",
|
|
7
|
+
"honk-me",
|
|
8
|
+
"notifications",
|
|
9
|
+
"push-notifications",
|
|
10
|
+
"alerts",
|
|
11
|
+
"alerting",
|
|
12
|
+
"monitoring",
|
|
13
|
+
"webhook",
|
|
14
|
+
"inbox",
|
|
15
|
+
"sdk",
|
|
16
|
+
"typescript"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/honk-me/honk-node#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/honk-me/honk-node/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/honk-me/honk-node.git"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Honk contributors",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./dist/cjs/index.js",
|
|
30
|
+
"module": "./dist/esm/index.js",
|
|
31
|
+
"types": "./dist/cjs/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/esm/index.d.ts",
|
|
36
|
+
"default": "./dist/esm/index.js"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist/cjs/index.d.ts",
|
|
40
|
+
"default": "./dist/cjs/index.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"CHANGELOG.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "node scripts/build.mjs",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p test/tsconfig.json --noEmit",
|
|
57
|
+
"test": "npm run build && node --test test/unit.test.mjs test/cjs.test.cjs",
|
|
58
|
+
"test:integration": "npm run build && node --test test/integration.test.mjs",
|
|
59
|
+
"prepack": "npm run build"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^18.19.0",
|
|
63
|
+
"typescript": "^7.0.2"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|