@phuzle/relay 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/LICENSE +21 -0
- package/README.md +44 -0
- package/cli.js +583 -0
- package/index.cjs +496 -0
- package/index.d.cts +148 -0
- package/index.d.ts +148 -0
- package/index.js +466 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Phuzle Labs
|
|
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,44 @@
|
|
|
1
|
+
# @phuzle/relay
|
|
2
|
+
|
|
3
|
+
Send **end-to-end encrypted** push notifications through [Relay](https://relay.phuzle.com) from Node, Bun or a shell.
|
|
4
|
+
The message is encrypted on your machine, one envelope per paired phone; Relay only routes ciphertext.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i @phuzle/relay # or: bun add @phuzle/relay
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { Relay, RelayError } from "@phuzle/relay";
|
|
12
|
+
|
|
13
|
+
const relay = new Relay({ apiKey: process.env.RELAY_API_KEY! }); // or Relay.fromEnv()
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const r = await relay.send({
|
|
17
|
+
topic: "acme-alerts",
|
|
18
|
+
title: "api-3 is unhealthy",
|
|
19
|
+
body: "3 failed health checks in a row",
|
|
20
|
+
priority: "urgent",
|
|
21
|
+
idempotencyKey: "healthcheck-api-3-2026-10-05T10",
|
|
22
|
+
});
|
|
23
|
+
console.log(r.accepted, r.rejected, r.missing);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e instanceof RelayError) console.error(e.status, e.code, e.message);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Plaintext over 2 KiB throws `PayloadTooLargeError`.
|
|
30
|
+
- `idempotencyKey` makes retries safe for 24 hours; transient failures are retried for you (`maxRetries`).
|
|
31
|
+
- Buttons (`actions`), images, scheduling (`deliverAt`, `delaySeconds`), `repeat` until acknowledged and `escalate` to another topic are supported.
|
|
32
|
+
- Point `baseUrl` at a local, staging or self-hosted Relay (default `https://relay.phuzle.com`).
|
|
33
|
+
|
|
34
|
+
## Command line
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export RELAY_API_KEY=rly_...
|
|
38
|
+
npx @phuzle/relay send --topic acme-alerts --title "Backup finished" --body "412 GB in 6 min"
|
|
39
|
+
# installed globally: relay send …
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Exit code `0` on success, `1` with a one-line reason on failure. `relay` with no arguments prints every option.
|
|
43
|
+
|
|
44
|
+
Requires Node 20+ (or Bun). Documentation: <https://docs.phuzle.com/relay>. MIT licensed.
|
package/cli.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../core/src/encoding.ts
|
|
4
|
+
function b64urlEncode(bytes) {
|
|
5
|
+
let s = "";
|
|
6
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
7
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
function b64urlDecode(text) {
|
|
10
|
+
const pad = "=".repeat((4 - text.length % 4) % 4);
|
|
11
|
+
const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
12
|
+
const out = new Uint8Array(bin.length);
|
|
13
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
var enc = new TextEncoder();
|
|
17
|
+
var utf8 = (s) => enc.encode(s);
|
|
18
|
+
|
|
19
|
+
// ../core/src/crypto/suite.ts
|
|
20
|
+
import { Chacha20Poly1305 } from "@hpke/chacha20poly1305";
|
|
21
|
+
import { CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
22
|
+
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
23
|
+
var suite = new CipherSuite({
|
|
24
|
+
kem: new DhkemX25519HkdfSha256(),
|
|
25
|
+
kdf: new HkdfSha256(),
|
|
26
|
+
aead: new Chacha20Poly1305()
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ../core/src/crypto/envelope.ts
|
|
30
|
+
var MAX_PLAINTEXT_BYTES = 2048;
|
|
31
|
+
var PayloadTooLargeError = class extends Error {
|
|
32
|
+
constructor(size) {
|
|
33
|
+
super(`Payload is ${size} bytes; limit is ${MAX_PLAINTEXT_BYTES}`);
|
|
34
|
+
this.name = "PayloadTooLargeError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var infoFor = (c) => utf8(`relay/v1\0${c.deviceId}\0${c.messageId}\0${c.topicId}`);
|
|
38
|
+
async function seal(encPub, ctx, plaintext, ekm) {
|
|
39
|
+
const pt = utf8(plaintext);
|
|
40
|
+
if (pt.length > MAX_PLAINTEXT_BYTES)
|
|
41
|
+
throw new PayloadTooLargeError(pt.length);
|
|
42
|
+
const recipientPublicKey = await suite.kem.deserializePublicKey(encPub);
|
|
43
|
+
const sender = await suite.createSenderContext({
|
|
44
|
+
recipientPublicKey,
|
|
45
|
+
info: infoFor(ctx),
|
|
46
|
+
...ekm ? { ekm } : {}
|
|
47
|
+
});
|
|
48
|
+
const ct = new Uint8Array(await sender.seal(pt));
|
|
49
|
+
return {
|
|
50
|
+
deviceId: ctx.deviceId,
|
|
51
|
+
enc: b64urlEncode(new Uint8Array(sender.enc)),
|
|
52
|
+
ct: b64urlEncode(ct)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ../core/src/crypto/keys.ts
|
|
57
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
58
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
59
|
+
function bindingMessage(encPub, idPub) {
|
|
60
|
+
return utf8(`relay-bind-v1
|
|
61
|
+
${b64urlEncode(encPub)}
|
|
62
|
+
${b64urlEncode(idPub)}`);
|
|
63
|
+
}
|
|
64
|
+
function verifyBinding(sig, encPub, idPub) {
|
|
65
|
+
try {
|
|
66
|
+
return ed25519.verify(sig, bindingMessage(encPub, idPub), idPub);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ../core/src/crypto/signing.ts
|
|
73
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
74
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2";
|
|
75
|
+
|
|
76
|
+
// ../core/src/lib/glyphs.ts
|
|
77
|
+
var GLYPHS = [
|
|
78
|
+
"activity",
|
|
79
|
+
"alert",
|
|
80
|
+
"battery",
|
|
81
|
+
"bell",
|
|
82
|
+
"bug",
|
|
83
|
+
"calendar",
|
|
84
|
+
"cart",
|
|
85
|
+
"check",
|
|
86
|
+
"clock",
|
|
87
|
+
"cloud",
|
|
88
|
+
"cpu",
|
|
89
|
+
"credit-card",
|
|
90
|
+
"database",
|
|
91
|
+
"dollar",
|
|
92
|
+
"error",
|
|
93
|
+
"flame",
|
|
94
|
+
"git-branch",
|
|
95
|
+
"git-pr",
|
|
96
|
+
"globe",
|
|
97
|
+
"heart",
|
|
98
|
+
"info",
|
|
99
|
+
"key",
|
|
100
|
+
"lock",
|
|
101
|
+
"mail",
|
|
102
|
+
"moon",
|
|
103
|
+
"package",
|
|
104
|
+
"phone",
|
|
105
|
+
"power",
|
|
106
|
+
"rocket",
|
|
107
|
+
"server",
|
|
108
|
+
"shield",
|
|
109
|
+
"star",
|
|
110
|
+
"success",
|
|
111
|
+
"terminal",
|
|
112
|
+
"thermometer",
|
|
113
|
+
"trending-down",
|
|
114
|
+
"trending-up",
|
|
115
|
+
"users",
|
|
116
|
+
"wifi",
|
|
117
|
+
"zap"
|
|
118
|
+
];
|
|
119
|
+
var isGlyph = (v) => typeof v === "string" && GLYPHS.includes(v);
|
|
120
|
+
|
|
121
|
+
// ../core/src/lib/ids.ts
|
|
122
|
+
var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
123
|
+
function ulid(now = Date.now()) {
|
|
124
|
+
let t = now;
|
|
125
|
+
let time = "";
|
|
126
|
+
for (let i = 0; i < 10; i++) {
|
|
127
|
+
time = CROCKFORD[t % 32] + time;
|
|
128
|
+
t = Math.floor(t / 32);
|
|
129
|
+
}
|
|
130
|
+
const r = crypto.getRandomValues(new Uint8Array(16));
|
|
131
|
+
let rand = "";
|
|
132
|
+
for (let i = 0; i < 16; i++) rand += CROCKFORD[r[i] % 32];
|
|
133
|
+
return time + rand;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/index.ts
|
|
137
|
+
var deliverAtIso = (m) => {
|
|
138
|
+
if (m.deliverAt !== void 0 && m.delaySeconds !== void 0)
|
|
139
|
+
throw new RelayError(
|
|
140
|
+
"Use deliverAt or delaySeconds, not both",
|
|
141
|
+
0,
|
|
142
|
+
"invalid_message"
|
|
143
|
+
);
|
|
144
|
+
const d = m.deliverAt !== void 0 ? new Date(m.deliverAt) : m.delaySeconds !== void 0 ? new Date(Date.now() + m.delaySeconds * 1e3) : void 0;
|
|
145
|
+
if (d && Number.isNaN(d.getTime()))
|
|
146
|
+
throw new RelayError("deliverAt is not a valid date", 0, "invalid_message");
|
|
147
|
+
return d?.toISOString();
|
|
148
|
+
};
|
|
149
|
+
var checkRepeat = (r) => {
|
|
150
|
+
if (!r) return;
|
|
151
|
+
if (!Number.isInteger(r.everySeconds) || r.everySeconds < 60 || r.everySeconds > 86400)
|
|
152
|
+
throw new RelayError(
|
|
153
|
+
"repeat.everySeconds must be 60 to 86400",
|
|
154
|
+
0,
|
|
155
|
+
"invalid_message"
|
|
156
|
+
);
|
|
157
|
+
if (!Number.isInteger(r.times) || r.times < 1 || r.times > 20)
|
|
158
|
+
throw new RelayError("repeat.times must be 1 to 20", 0, "invalid_message");
|
|
159
|
+
};
|
|
160
|
+
var RelayError = class extends Error {
|
|
161
|
+
constructor(message, status, code, retryAfterSeconds) {
|
|
162
|
+
super(message);
|
|
163
|
+
this.status = status;
|
|
164
|
+
this.code = code;
|
|
165
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
166
|
+
this.name = "RelayError";
|
|
167
|
+
}
|
|
168
|
+
status;
|
|
169
|
+
code;
|
|
170
|
+
retryAfterSeconds;
|
|
171
|
+
};
|
|
172
|
+
var DEFAULT_URL = "https://relay.phuzle.com";
|
|
173
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
174
|
+
var Relay = class _Relay {
|
|
175
|
+
constructor(opts) {
|
|
176
|
+
this.opts = opts;
|
|
177
|
+
if (!opts.apiKey?.startsWith("rly_"))
|
|
178
|
+
throw new RelayError(
|
|
179
|
+
"apiKey must be a Relay API key (rly_\u2026)",
|
|
180
|
+
0,
|
|
181
|
+
"bad_api_key"
|
|
182
|
+
);
|
|
183
|
+
const base = (opts.baseUrl || DEFAULT_URL).trim();
|
|
184
|
+
if (!/^https?:\/\/[^\s/]+/i.test(base))
|
|
185
|
+
throw new RelayError(
|
|
186
|
+
`baseUrl must be an http(s) URL, got "${base}"`,
|
|
187
|
+
0,
|
|
188
|
+
"bad_base_url"
|
|
189
|
+
);
|
|
190
|
+
this.base = base.replace(/\/+$/, "");
|
|
191
|
+
this.doFetch = opts.fetch ?? fetch;
|
|
192
|
+
this.retries = opts.maxRetries ?? 2;
|
|
193
|
+
}
|
|
194
|
+
opts;
|
|
195
|
+
base;
|
|
196
|
+
doFetch;
|
|
197
|
+
retries;
|
|
198
|
+
cache = /* @__PURE__ */ new Map();
|
|
199
|
+
/** Trust-on-first-use: a device id must keep the same identity key, or something is swapping keys (crypto-spec §3, T4). */
|
|
200
|
+
pinned = /* @__PURE__ */ new Map();
|
|
201
|
+
/** Reads `RELAY_API_KEY` and, optionally, `RELAY_API_URL`; `overrides` win over the environment. */
|
|
202
|
+
static fromEnv(env = process.env, overrides = {}) {
|
|
203
|
+
return new _Relay({
|
|
204
|
+
apiKey: env.RELAY_API_KEY ?? "",
|
|
205
|
+
baseUrl: overrides.baseUrl || env.RELAY_API_URL,
|
|
206
|
+
...overrides.fetch && { fetch: overrides.fetch },
|
|
207
|
+
...overrides.maxRetries !== void 0 && {
|
|
208
|
+
maxRetries: overrides.maxRetries
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async send(m) {
|
|
213
|
+
checkRepeat(m.repeat);
|
|
214
|
+
checkRepeat(m.escalate?.repeat);
|
|
215
|
+
if (m.escalate && (!Number.isFinite(m.escalate.afterSeconds) || m.escalate.afterSeconds < 30))
|
|
216
|
+
throw new RelayError(
|
|
217
|
+
"escalate.afterSeconds must be at least 30",
|
|
218
|
+
0,
|
|
219
|
+
"invalid_message"
|
|
220
|
+
);
|
|
221
|
+
const result = await this.sendCore(m);
|
|
222
|
+
if (!m.escalate) return result;
|
|
223
|
+
const e = m.escalate;
|
|
224
|
+
const base = new Date(deliverAtIso(m) ?? Date.now());
|
|
225
|
+
result.escalation = await this.sendCore({
|
|
226
|
+
...m,
|
|
227
|
+
topic: e.topic,
|
|
228
|
+
title: e.title ?? m.title,
|
|
229
|
+
body: e.body ?? m.body,
|
|
230
|
+
priority: e.priority ?? "urgent",
|
|
231
|
+
repeat: e.repeat,
|
|
232
|
+
escalate: void 0,
|
|
233
|
+
idempotencyKey: void 0,
|
|
234
|
+
delaySeconds: void 0,
|
|
235
|
+
deliverAt: new Date(base.getTime() + e.afterSeconds * 1e3),
|
|
236
|
+
unlessAcked: result.messageId
|
|
237
|
+
});
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
async sendCore(m) {
|
|
241
|
+
const plaintext = buildPlaintext(m);
|
|
242
|
+
let result = await this.sendOnce(
|
|
243
|
+
m,
|
|
244
|
+
plaintext,
|
|
245
|
+
await this.recipients(m.topic)
|
|
246
|
+
);
|
|
247
|
+
if (result.missing.length > 0) {
|
|
248
|
+
const fresh = await this.recipients(m.topic, true);
|
|
249
|
+
const only = {
|
|
250
|
+
...fresh,
|
|
251
|
+
recipients: fresh.recipients.filter(
|
|
252
|
+
(r) => result.missing.includes(r.deviceId)
|
|
253
|
+
)
|
|
254
|
+
};
|
|
255
|
+
if (only.recipients.length > 0) {
|
|
256
|
+
const extra = await this.sendOnce(
|
|
257
|
+
{ ...m, idempotencyKey: void 0 },
|
|
258
|
+
plaintext,
|
|
259
|
+
only
|
|
260
|
+
);
|
|
261
|
+
result = {
|
|
262
|
+
...result,
|
|
263
|
+
accepted: result.accepted + extra.accepted,
|
|
264
|
+
// `extra.missing` lists devices we deliberately left out of the second request; keep only ones we still failed to reach.
|
|
265
|
+
missing: result.missing.filter(
|
|
266
|
+
(id) => !only.recipients.some((r) => r.deviceId === id) || extra.rejected.some((x) => x.deviceId === id)
|
|
267
|
+
),
|
|
268
|
+
rejected: [...result.rejected, ...extra.rejected]
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
async sendOnce(m, plaintext, set) {
|
|
275
|
+
if (set.recipients.length === 0)
|
|
276
|
+
throw new RelayError(
|
|
277
|
+
`No devices are subscribed to ${m.topic} yet \u2014 pair a phone first.`,
|
|
278
|
+
0,
|
|
279
|
+
"no_recipients"
|
|
280
|
+
);
|
|
281
|
+
const messageId = ulid();
|
|
282
|
+
const body = JSON.stringify({ v: 1, id: messageId, ...plaintext });
|
|
283
|
+
const envelopes = await Promise.all(
|
|
284
|
+
set.recipients.map(
|
|
285
|
+
(r) => seal(
|
|
286
|
+
b64urlDecode(r.encPub),
|
|
287
|
+
{ messageId, topicId: set.topicId, deviceId: r.deviceId },
|
|
288
|
+
body
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
const res = await this.request(
|
|
293
|
+
"POST",
|
|
294
|
+
"/v1/trigger",
|
|
295
|
+
{
|
|
296
|
+
topic: m.topic,
|
|
297
|
+
messageId,
|
|
298
|
+
priority: m.priority ?? "default",
|
|
299
|
+
ttlSeconds: m.ttlSeconds ?? 86400,
|
|
300
|
+
...m.collapseKey ? { collapseKey: m.collapseKey } : {},
|
|
301
|
+
...deliverAtIso(m) ? { deliverAt: deliverAtIso(m) } : {},
|
|
302
|
+
...m.repeat ? { repeat: m.repeat } : {},
|
|
303
|
+
...m.unlessAcked ? { unlessAcked: m.unlessAcked } : {},
|
|
304
|
+
envelopes
|
|
305
|
+
},
|
|
306
|
+
{ "Idempotency-Key": m.idempotencyKey ?? messageId }
|
|
307
|
+
);
|
|
308
|
+
return res;
|
|
309
|
+
}
|
|
310
|
+
/** Recipients for a topic, verified and cached by ETag (a cheap conditional GET on every send keeps the key set fresh). */
|
|
311
|
+
async recipients(topic, bypass = false) {
|
|
312
|
+
const cached = this.cache.get(topic);
|
|
313
|
+
const res = await this.raw(
|
|
314
|
+
"GET",
|
|
315
|
+
`/v1/trigger/recipients?topic=${encodeURIComponent(topic)}`,
|
|
316
|
+
void 0,
|
|
317
|
+
!bypass && cached ? { "If-None-Match": cached.etag } : {}
|
|
318
|
+
);
|
|
319
|
+
if (res.status === 304 && cached) return cached.set;
|
|
320
|
+
const set = await this.parse(res);
|
|
321
|
+
for (const r of set.recipients) {
|
|
322
|
+
if (!verifyBinding(
|
|
323
|
+
b64urlDecode(r.bindSig),
|
|
324
|
+
b64urlDecode(r.encPub),
|
|
325
|
+
b64urlDecode(r.idPub)
|
|
326
|
+
))
|
|
327
|
+
throw new RelayError(
|
|
328
|
+
`Device ${r.deviceId} returned an invalid key binding`,
|
|
329
|
+
0,
|
|
330
|
+
"bad_key_binding"
|
|
331
|
+
);
|
|
332
|
+
const pin = this.pinned.get(r.deviceId);
|
|
333
|
+
if (pin && pin !== r.idPub)
|
|
334
|
+
throw new RelayError(
|
|
335
|
+
`Device ${r.deviceId} changed its identity key`,
|
|
336
|
+
0,
|
|
337
|
+
"key_changed"
|
|
338
|
+
);
|
|
339
|
+
this.pinned.set(r.deviceId, r.idPub);
|
|
340
|
+
}
|
|
341
|
+
const etag = res.headers.get("etag");
|
|
342
|
+
if (etag) this.cache.set(topic, { etag, set });
|
|
343
|
+
return set;
|
|
344
|
+
}
|
|
345
|
+
async request(method, path, body, headers = {}) {
|
|
346
|
+
let attempt = 0;
|
|
347
|
+
for (; ; ) {
|
|
348
|
+
try {
|
|
349
|
+
return await this.parse(await this.raw(method, path, body, headers));
|
|
350
|
+
} catch (e) {
|
|
351
|
+
const transient = e instanceof RelayError ? e.status >= 500 || e.status === 0 && e.code === "network" : false;
|
|
352
|
+
if (!transient || attempt >= this.retries) throw e;
|
|
353
|
+
await sleep(250 * 2 ** attempt++);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async raw(method, path, body, headers = {}) {
|
|
358
|
+
try {
|
|
359
|
+
return await this.doFetch(this.base + path, {
|
|
360
|
+
method,
|
|
361
|
+
headers: {
|
|
362
|
+
authorization: `Bearer ${this.opts.apiKey}`,
|
|
363
|
+
accept: "application/json",
|
|
364
|
+
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
365
|
+
...headers
|
|
366
|
+
},
|
|
367
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
368
|
+
});
|
|
369
|
+
} catch {
|
|
370
|
+
throw new RelayError("Couldn't reach Relay", 0, "network");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async parse(res) {
|
|
374
|
+
const text = await res.text();
|
|
375
|
+
const json = text ? safeJson(text) : void 0;
|
|
376
|
+
if (res.ok) return json;
|
|
377
|
+
const retry = Number(res.headers.get("retry-after"));
|
|
378
|
+
throw new RelayError(
|
|
379
|
+
json?.error ?? `Relay returned ${res.status}`,
|
|
380
|
+
res.status,
|
|
381
|
+
json?.code ?? `http_${res.status}`,
|
|
382
|
+
Number.isFinite(retry) && retry > 0 ? retry : void 0
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
var safeJson = (t) => {
|
|
387
|
+
try {
|
|
388
|
+
return JSON.parse(t);
|
|
389
|
+
} catch {
|
|
390
|
+
return void 0;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
function buildPlaintext(m) {
|
|
394
|
+
const bad = (msg, code) => new RelayError(msg, 0, code);
|
|
395
|
+
if (!m.title?.trim()) throw bad("A title is required", "invalid_message");
|
|
396
|
+
if (m.title.length > 120)
|
|
397
|
+
throw bad("Title is limited to 120 characters", "invalid_message");
|
|
398
|
+
if ((m.body?.length ?? 0) > 1e3)
|
|
399
|
+
throw bad("Body is limited to 1000 characters", "invalid_message");
|
|
400
|
+
if ((m.tags?.length ?? 0) > 5 || m.tags?.some((t) => t.length > 24))
|
|
401
|
+
throw bad("Up to 5 tags, 24 characters each", "invalid_message");
|
|
402
|
+
if (m.icon !== void 0 && !isGlyph(m.icon))
|
|
403
|
+
throw bad(
|
|
404
|
+
`Unknown icon "${m.icon}". Use one of: ${GLYPHS.join(", ")}`,
|
|
405
|
+
"invalid_message"
|
|
406
|
+
);
|
|
407
|
+
if (m.url && !/^https:\/\//i.test(m.url))
|
|
408
|
+
throw bad("url must be https", "invalid_message");
|
|
409
|
+
if (m.image && (!/^https:\/\//i.test(m.image) || m.image.length > 1e3))
|
|
410
|
+
throw bad(
|
|
411
|
+
"image must be an https link of at most 1000 characters",
|
|
412
|
+
"invalid_message"
|
|
413
|
+
);
|
|
414
|
+
if ((m.actions?.length ?? 0) > 3)
|
|
415
|
+
throw bad("At most 3 actions", "invalid_message");
|
|
416
|
+
for (const raw of m.actions ?? []) {
|
|
417
|
+
const a = raw;
|
|
418
|
+
if (!a.label?.trim() || a.label.length > 30)
|
|
419
|
+
throw bad(
|
|
420
|
+
"Action labels are required and limited to 30 characters",
|
|
421
|
+
"invalid_message"
|
|
422
|
+
);
|
|
423
|
+
if (a.link !== void 0 && a.request !== void 0)
|
|
424
|
+
throw bad(
|
|
425
|
+
`Action "${a.label}": use either request or link, not both`,
|
|
426
|
+
"invalid_message"
|
|
427
|
+
);
|
|
428
|
+
if (a.link !== void 0) {
|
|
429
|
+
if (!/^https:\/\//i.test(a.link))
|
|
430
|
+
throw bad(`Action "${a.label}": link must be https`, "invalid_message");
|
|
431
|
+
if (a.link.length > 1e3)
|
|
432
|
+
throw bad(
|
|
433
|
+
`Action "${a.label}": link is limited to 1000 characters`,
|
|
434
|
+
"invalid_message"
|
|
435
|
+
);
|
|
436
|
+
} else if (!a.request?.url || !/^https:\/\//i.test(a.request.url))
|
|
437
|
+
throw bad(`Action "${a.label}": url must be https`, "invalid_message");
|
|
438
|
+
}
|
|
439
|
+
const actions = m.actions?.map((a, i) => {
|
|
440
|
+
const id = a.id ?? `a${i + 1}`;
|
|
441
|
+
if (a.link !== void 0) return { id, label: a.label, link: a.link };
|
|
442
|
+
return {
|
|
443
|
+
style: "default",
|
|
444
|
+
confirm: a.style === "destructive",
|
|
445
|
+
retry: false,
|
|
446
|
+
...a,
|
|
447
|
+
id
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
return {
|
|
451
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
452
|
+
title: m.title,
|
|
453
|
+
...m.body ? { body: m.body } : {},
|
|
454
|
+
priority: m.priority ?? "default",
|
|
455
|
+
...m.tags?.length ? { tags: m.tags } : {},
|
|
456
|
+
...m.icon ? { icon: m.icon } : {},
|
|
457
|
+
...m.url ? { url: m.url } : {},
|
|
458
|
+
...m.image ? { image: m.image } : {},
|
|
459
|
+
...m.group ? { group: m.group } : {},
|
|
460
|
+
...actions ? { actions } : {}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/cli.ts
|
|
465
|
+
var USAGE = `relay send --topic <org-topic> --title <text> [--body <text>] [--priority min|low|default|high|urgent]
|
|
466
|
+
[--url <https url>] [--image <https url>] [--icon <name>] [--tag <t>]... [--group <g>] [--ttl <seconds>]
|
|
467
|
+
[--api-url <https://your-relay>] [--action "Label=METHOD https://url"]... [--link "Label=https://url"]...
|
|
468
|
+
(up to 3 buttons in total. --action sends a request from the phone; add "!" after the label for destructive: "Reboot!=POST \u2026".
|
|
469
|
+
--link just opens the page in the browser: "Dashboard=https://app.example.com/deploys/42")
|
|
470
|
+
[--at <ISO time> | --delay 10m] [--repeat 5m:3] [--escalate-topic <org-topic> --escalate-after 10m [--escalate-title <text>]]
|
|
471
|
+
(--delay/--at hold delivery; --repeat re-sends every 5 minutes up to 3 more times until a phone acknowledges it;
|
|
472
|
+
--escalate-* notifies another topic if nobody acknowledged in time. Durations: 90s, 10m, 2h, 1d.)
|
|
473
|
+
Environment: RELAY_API_KEY (required), RELAY_API_URL or --api-url (default https://relay.phuzle.com)
|
|
474
|
+
The message is encrypted on this machine; Relay only routes ciphertext.`;
|
|
475
|
+
function parse(argv) {
|
|
476
|
+
const flags = /* @__PURE__ */ new Map();
|
|
477
|
+
for (let i = 0; i < argv.length; i++) {
|
|
478
|
+
const a = argv[i];
|
|
479
|
+
if (!a.startsWith("--")) continue;
|
|
480
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
481
|
+
flags.set(a.slice(2), [...flags.get(a.slice(2)) ?? [], v]);
|
|
482
|
+
}
|
|
483
|
+
return flags;
|
|
484
|
+
}
|
|
485
|
+
function action(spec) {
|
|
486
|
+
const eq = spec.indexOf("=");
|
|
487
|
+
if (eq < 1)
|
|
488
|
+
throw new Error(`Bad --action "${spec}". Use "Label=METHOD https://url"`);
|
|
489
|
+
let label = spec.slice(0, eq).trim();
|
|
490
|
+
const destructive = label.endsWith("!");
|
|
491
|
+
if (destructive) label = label.slice(0, -1);
|
|
492
|
+
const [method, url] = spec.slice(eq + 1).trim().split(/\s+/, 2);
|
|
493
|
+
if (!url) throw new Error(`Bad --action "${spec}": missing URL`);
|
|
494
|
+
return {
|
|
495
|
+
label,
|
|
496
|
+
style: destructive ? "destructive" : "default",
|
|
497
|
+
request: {
|
|
498
|
+
method: method.toUpperCase(),
|
|
499
|
+
url
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function repeatSpec(s) {
|
|
504
|
+
const [every, times] = s.split(":");
|
|
505
|
+
if (!every || !times)
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Bad --repeat "${s}". Use 5m:3 (every 5 minutes, up to 3 more times)`
|
|
508
|
+
);
|
|
509
|
+
return { everySeconds: duration(every, "--repeat"), times: Number(times) };
|
|
510
|
+
}
|
|
511
|
+
function duration(s, flag = "duration") {
|
|
512
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhd]?)$/i.exec(s.trim());
|
|
513
|
+
if (!m) throw new Error(`Bad ${flag} "${s}". Use 90s, 10m, 2h or 1d`);
|
|
514
|
+
const n = Number(m[1]) * { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
|
|
515
|
+
return Math.round(n);
|
|
516
|
+
}
|
|
517
|
+
function link(spec) {
|
|
518
|
+
const eq = spec.indexOf("=");
|
|
519
|
+
if (eq < 1) throw new Error(`Bad --link "${spec}". Use "Label=https://url"`);
|
|
520
|
+
const url = spec.slice(eq + 1).trim();
|
|
521
|
+
if (!url) throw new Error(`Bad --link "${spec}": missing URL`);
|
|
522
|
+
return { label: spec.slice(0, eq).trim(), link: url };
|
|
523
|
+
}
|
|
524
|
+
async function main(argv, env = process.env, out = console) {
|
|
525
|
+
const [cmd, ...rest] = argv;
|
|
526
|
+
if (cmd !== "send" || rest.includes("--help")) {
|
|
527
|
+
out.log(USAGE);
|
|
528
|
+
return cmd === "send" || !cmd ? 0 : 1;
|
|
529
|
+
}
|
|
530
|
+
const f = parse(rest);
|
|
531
|
+
const one = (k) => f.get(k)?.[0];
|
|
532
|
+
try {
|
|
533
|
+
const topic = one("topic");
|
|
534
|
+
if (!topic || !one("title"))
|
|
535
|
+
throw new Error("--topic and --title are required");
|
|
536
|
+
const r = await Relay.fromEnv(env, { baseUrl: one("api-url") }).send({
|
|
537
|
+
topic,
|
|
538
|
+
title: one("title"),
|
|
539
|
+
body: one("body"),
|
|
540
|
+
priority: one("priority"),
|
|
541
|
+
url: one("url"),
|
|
542
|
+
image: one("image"),
|
|
543
|
+
icon: one("icon"),
|
|
544
|
+
group: one("group"),
|
|
545
|
+
tags: f.get("tag"),
|
|
546
|
+
ttlSeconds: one("ttl") ? Number(one("ttl")) : void 0,
|
|
547
|
+
deliverAt: one("at"),
|
|
548
|
+
delaySeconds: one("delay") ? duration(one("delay"), "--delay") : void 0,
|
|
549
|
+
repeat: one("repeat") ? repeatSpec(one("repeat")) : void 0,
|
|
550
|
+
escalate: one("escalate-topic") ? {
|
|
551
|
+
topic: one("escalate-topic"),
|
|
552
|
+
afterSeconds: duration(
|
|
553
|
+
one("escalate-after") ?? "",
|
|
554
|
+
"--escalate-after"
|
|
555
|
+
),
|
|
556
|
+
title: one("escalate-title")
|
|
557
|
+
} : void 0,
|
|
558
|
+
actions: [
|
|
559
|
+
...f.get("action")?.map(action) ?? [],
|
|
560
|
+
...f.get("link")?.map(link) ?? []
|
|
561
|
+
]
|
|
562
|
+
});
|
|
563
|
+
if (r.scheduledFor) out.log(`scheduled for ${r.scheduledFor}`);
|
|
564
|
+
if (r.escalation)
|
|
565
|
+
out.log(
|
|
566
|
+
`escalation to ${one("escalate-topic")} armed: ${r.escalation.messageId}`
|
|
567
|
+
);
|
|
568
|
+
out.log(
|
|
569
|
+
`sent ${r.messageId} \u2192 ${r.accepted} device${r.accepted === 1 ? "" : "s"}${r.rejected.length ? ` (${r.rejected.length} rejected)` : ""}`
|
|
570
|
+
);
|
|
571
|
+
return 0;
|
|
572
|
+
} catch (e) {
|
|
573
|
+
out.error(
|
|
574
|
+
e instanceof RelayError ? `relay: ${e.message} [${e.code}]${e.retryAfterSeconds ? ` (retry in ${e.retryAfterSeconds}s)` : ""}` : `relay: ${e.message}`
|
|
575
|
+
);
|
|
576
|
+
return 1;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (import.meta.main) process.exit(await main(process.argv.slice(2)));
|
|
580
|
+
export {
|
|
581
|
+
duration,
|
|
582
|
+
main
|
|
583
|
+
};
|