@snakechat/commskit-node 0.0.0-stage → 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 +87 -2
- package/dist/adapters-8ztjEYtz.d.ts +83 -0
- package/dist/call.d.ts +34 -0
- package/dist/call.js +51 -0
- package/dist/call.js.map +1 -0
- package/dist/chat.d.ts +64 -0
- package/dist/chat.js +30 -0
- package/dist/chat.js.map +1 -0
- package/dist/chunk-5BJ2SNM5.js +26 -0
- package/dist/chunk-5BJ2SNM5.js.map +1 -0
- package/dist/chunk-6I27CI32.js +586 -0
- package/dist/chunk-6I27CI32.js.map +1 -0
- package/dist/chunk-BBFVG7QU.js +52 -0
- package/dist/chunk-BBFVG7QU.js.map +1 -0
- package/dist/chunk-GR4PMGCS.js +82 -0
- package/dist/chunk-GR4PMGCS.js.map +1 -0
- package/dist/chunk-KL4WRZKZ.js +799 -0
- package/dist/chunk-KL4WRZKZ.js.map +1 -0
- package/dist/chunk-KLKRK6OG.js +107 -0
- package/dist/chunk-KLKRK6OG.js.map +1 -0
- package/dist/chunk-LY2GJWN7.js +708 -0
- package/dist/chunk-LY2GJWN7.js.map +1 -0
- package/dist/chunk-SEDRF62Q.js +39 -0
- package/dist/chunk-SEDRF62Q.js.map +1 -0
- package/dist/chunk-U4Z5INIW.js +1192 -0
- package/dist/chunk-U4Z5INIW.js.map +1 -0
- package/dist/chunk-XOXZZYSW.js +247 -0
- package/dist/chunk-XOXZZYSW.js.map +1 -0
- package/dist/chunk-XQ6FLRX7.js +179 -0
- package/dist/chunk-XQ6FLRX7.js.map +1 -0
- package/dist/chunk-XQFQN73U.js +38 -0
- package/dist/chunk-XQFQN73U.js.map +1 -0
- package/dist/connections-CGEPiZPF.d.ts +128 -0
- package/dist/core.d.ts +26 -0
- package/dist/core.js +89 -0
- package/dist/core.js.map +1 -0
- package/dist/devices-oLIB_M19.d.ts +97 -0
- package/dist/express.d.ts +19 -0
- package/dist/express.js +34 -0
- package/dist/express.js.map +1 -0
- package/dist/fastify.d.ts +22 -0
- package/dist/fastify.js +42 -0
- package/dist/fastify.js.map +1 -0
- package/dist/feature-B2bEXLZG.d.ts +233 -0
- package/dist/feature-DQUbnYE-.d.ts +284 -0
- package/dist/feature-DwKkuOBh.d.ts +195 -0
- package/dist/hub-CPrWmIvB.d.ts +174 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +97 -0
- package/dist/integration.js +151 -0
- package/dist/integration.js.map +1 -0
- package/dist/models-D1lehXIu.d.ts +31 -0
- package/dist/models-DTXEX1Q8.d.ts +34 -0
- package/dist/moderation.d.ts +9 -0
- package/dist/moderation.js +48 -0
- package/dist/moderation.js.map +1 -0
- package/dist/push.d.ts +198 -0
- package/dist/push.js +475 -0
- package/dist/push.js.map +1 -0
- package/dist/redis.d.ts +103 -0
- package/dist/redis.js +296 -0
- package/dist/redis.js.map +1 -0
- package/dist/s3.d.ts +75 -0
- package/dist/s3.js +315 -0
- package/dist/s3.js.map +1 -0
- package/dist/util-B32rIBVA.d.ts +22 -0
- package/package.json +148 -6
package/dist/push.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DevicesFeature,
|
|
3
|
+
PLATFORMS,
|
|
4
|
+
SqlDeviceRegistry,
|
|
5
|
+
buildDevicesRoutes,
|
|
6
|
+
devicesDdl
|
|
7
|
+
} from "./chunk-XOXZZYSW.js";
|
|
8
|
+
import "./chunk-GR4PMGCS.js";
|
|
9
|
+
import {
|
|
10
|
+
logger
|
|
11
|
+
} from "./chunk-5BJ2SNM5.js";
|
|
12
|
+
|
|
13
|
+
// src/core/push.ts
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
import http2 from "http2";
|
|
17
|
+
import { SignJWT, importPKCS8 } from "jose";
|
|
18
|
+
var EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
|
|
19
|
+
var EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
20
|
+
var APNS_PRODUCTION_HOST = "https://api.push.apple.com";
|
|
21
|
+
var APNS_SANDBOX_HOST = "https://api.sandbox.push.apple.com";
|
|
22
|
+
var INCOMING_CALL = "incoming_call";
|
|
23
|
+
var NAMESPACE_URL = Buffer.from("6ba7b8119dad11d180b400c04fd430c8", "hex");
|
|
24
|
+
function callkitUuid(callId) {
|
|
25
|
+
const h = createHash("sha1").update(NAMESPACE_URL).update(`commskit:call:${callId}`, "utf8").digest().subarray(0, 16);
|
|
26
|
+
h[6] = h[6] & 15 | 80;
|
|
27
|
+
h[8] = h[8] & 63 | 128;
|
|
28
|
+
const x = h.toString("hex");
|
|
29
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`;
|
|
30
|
+
}
|
|
31
|
+
var ExpoPushSender = class {
|
|
32
|
+
registry;
|
|
33
|
+
accessToken;
|
|
34
|
+
url;
|
|
35
|
+
batchSize;
|
|
36
|
+
timeout;
|
|
37
|
+
sound;
|
|
38
|
+
receiptsUrl;
|
|
39
|
+
receiptBatchSize;
|
|
40
|
+
maxPendingTickets;
|
|
41
|
+
receiptTtl;
|
|
42
|
+
fetchImpl;
|
|
43
|
+
tickets = /* @__PURE__ */ new Map();
|
|
44
|
+
// ticket id -> [token, sent at (ms)]
|
|
45
|
+
poller = null;
|
|
46
|
+
constructor(opts = {}) {
|
|
47
|
+
const batchSize = opts.batchSize ?? 100;
|
|
48
|
+
const receiptBatchSize = opts.receiptBatchSize ?? 1e3;
|
|
49
|
+
if (!(batchSize >= 1 && batchSize <= 100)) throw new Error("Expo accepts at most 100 messages per request");
|
|
50
|
+
if (!(receiptBatchSize >= 1 && receiptBatchSize <= 1e3)) throw new Error("Expo accepts at most 1000 receipt ids per request");
|
|
51
|
+
this.registry = opts.registry ?? null;
|
|
52
|
+
this.accessToken = opts.accessToken ?? null;
|
|
53
|
+
this.fetchImpl = opts.fetch;
|
|
54
|
+
this.url = opts.url ?? EXPO_PUSH_URL;
|
|
55
|
+
this.batchSize = batchSize;
|
|
56
|
+
this.timeout = opts.timeout ?? 10;
|
|
57
|
+
this.sound = opts.sound === void 0 ? "default" : opts.sound;
|
|
58
|
+
this.receiptsUrl = opts.receiptsUrl ?? EXPO_RECEIPTS_URL;
|
|
59
|
+
this.receiptBatchSize = receiptBatchSize;
|
|
60
|
+
this.maxPendingTickets = opts.maxPendingTickets ?? 5e4;
|
|
61
|
+
this.receiptTtl = opts.receiptTtl ?? 24 * 3600;
|
|
62
|
+
}
|
|
63
|
+
headers() {
|
|
64
|
+
const h = {
|
|
65
|
+
Accept: "application/json",
|
|
66
|
+
"Accept-Encoding": "gzip, deflate",
|
|
67
|
+
"Content-Type": "application/json"
|
|
68
|
+
};
|
|
69
|
+
if (this.accessToken) h.Authorization = `Bearer ${this.accessToken}`;
|
|
70
|
+
return h;
|
|
71
|
+
}
|
|
72
|
+
async post(url, payload) {
|
|
73
|
+
const f = this.fetchImpl ?? globalThis.fetch;
|
|
74
|
+
const res = await f(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: this.headers(),
|
|
77
|
+
body: JSON.stringify(payload),
|
|
78
|
+
signal: AbortSignal.timeout(this.timeout * 1e3)
|
|
79
|
+
});
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
let body = {};
|
|
82
|
+
if (text) {
|
|
83
|
+
try {
|
|
84
|
+
body = JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
body = {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { status: res.status, body };
|
|
90
|
+
}
|
|
91
|
+
/** Nothing to release by default (global fetch); stops the receipt poller. */
|
|
92
|
+
async aclose() {
|
|
93
|
+
this.stopReceiptPoller();
|
|
94
|
+
}
|
|
95
|
+
/** One Expo message. `dataOnly` omits title/body/sound: Android hands it to the app instead of displaying it. */
|
|
96
|
+
buildMessage(token, title, body, data, opts = {}) {
|
|
97
|
+
const { channel = "default", ttl = null, priority = "high", sound = false, dataOnly = false } = opts;
|
|
98
|
+
const message = { to: token, data: { ...data }, priority };
|
|
99
|
+
if (!dataOnly) {
|
|
100
|
+
message.title = title;
|
|
101
|
+
message.body = body;
|
|
102
|
+
const chosen = sound === false ? this.sound : sound;
|
|
103
|
+
if (chosen) message.sound = chosen;
|
|
104
|
+
} else {
|
|
105
|
+
message._contentAvailable = true;
|
|
106
|
+
}
|
|
107
|
+
if (channel) message.channelId = channel;
|
|
108
|
+
if (ttl !== null && ttl !== void 0) message.ttl = Math.trunc(ttl);
|
|
109
|
+
return message;
|
|
110
|
+
}
|
|
111
|
+
/** POST in batches of `batchSize`. One ticket per message, in order; a failed batch yields error tickets. */
|
|
112
|
+
async sendMessages(messages) {
|
|
113
|
+
const tickets = [];
|
|
114
|
+
for (let start = 0; start < messages.length; start += this.batchSize) {
|
|
115
|
+
const batch = messages.slice(start, start + this.batchSize).map((m) => ({ ...m }));
|
|
116
|
+
let batchTickets;
|
|
117
|
+
try {
|
|
118
|
+
const { status, body } = await this.post(this.url, batch);
|
|
119
|
+
if (status >= 400 || body?.errors) logger.warn(`Expo push batch failed (${status})`, body?.errors ?? body);
|
|
120
|
+
const data = body && typeof body === "object" ? body.data : null;
|
|
121
|
+
if (Array.isArray(data) && data.length === batch.length) {
|
|
122
|
+
batchTickets = data.map((t) => t && typeof t === "object" ? t : {});
|
|
123
|
+
} else {
|
|
124
|
+
batchTickets = batch.map(() => ({ status: "error", message: `HTTP ${status}` }));
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.error("Expo push request failed", err);
|
|
128
|
+
batchTickets = batch.map(() => ({ status: "error", message: String(err?.message ?? err) }));
|
|
129
|
+
}
|
|
130
|
+
await this.handleTickets(batch, batchTickets);
|
|
131
|
+
this.rememberTickets(batch, batchTickets);
|
|
132
|
+
tickets.push(...batchTickets);
|
|
133
|
+
}
|
|
134
|
+
return tickets;
|
|
135
|
+
}
|
|
136
|
+
async dropToken(token) {
|
|
137
|
+
if (!token || !this.registry) return;
|
|
138
|
+
try {
|
|
139
|
+
await this.registry.removeToken(String(token), "expo");
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.error("Removing an unregistered Expo token failed", err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async handleTickets(batch, tickets) {
|
|
145
|
+
for (let i = 0; i < batch.length; i++) {
|
|
146
|
+
const ticket = tickets[i];
|
|
147
|
+
if (ticket.status !== "error") continue;
|
|
148
|
+
const details = ticket.details ?? {};
|
|
149
|
+
if (details.error === "DeviceNotRegistered") await this.dropToken(details.expoPushToken || batch[i].to);
|
|
150
|
+
else logger.warn("Expo push error", ticket.message ?? details);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
rememberTickets(batch, tickets) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
for (let i = 0; i < batch.length; i++) {
|
|
156
|
+
const t = tickets[i];
|
|
157
|
+
if (t.status === "ok" && t.id && batch[i].to) {
|
|
158
|
+
this.tickets.delete(String(t.id));
|
|
159
|
+
this.tickets.set(String(t.id), [String(batch[i].to), now]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
while (this.tickets.size > this.maxPendingTickets) this.tickets.delete(this.tickets.keys().next().value);
|
|
163
|
+
}
|
|
164
|
+
/** `{ticketId: expoPushToken}` still waiting for a receipt. */
|
|
165
|
+
get pendingTickets() {
|
|
166
|
+
return Object.fromEntries([...this.tickets].map(([id, [token]]) => [id, token]));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fetch receipts for the remembered tickets (`getReceipts`, at most `receiptBatchSize` ids per request).
|
|
170
|
+
* `DeviceNotRegistered` removes the token. Tickets with a receipt are forgotten; others stay pending until
|
|
171
|
+
* `receiptTtl`. Returns `{checked, ok, errors, unregistered, pending}`.
|
|
172
|
+
*/
|
|
173
|
+
async pollReceipts() {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
for (const [id, [, at]] of [...this.tickets]) if (now - at > this.receiptTtl * 1e3) this.tickets.delete(id);
|
|
176
|
+
const ids = [...this.tickets.keys()];
|
|
177
|
+
const summary = { checked: ids.length, ok: 0, errors: 0, unregistered: 0, pending: 0 };
|
|
178
|
+
if (!ids.length) return summary;
|
|
179
|
+
const receipts = {};
|
|
180
|
+
for (let start = 0; start < ids.length; start += this.receiptBatchSize) {
|
|
181
|
+
const chunk = ids.slice(start, start + this.receiptBatchSize);
|
|
182
|
+
try {
|
|
183
|
+
const { status, body } = await this.post(this.receiptsUrl, { ids: chunk });
|
|
184
|
+
const data = body && typeof body === "object" ? body.data : null;
|
|
185
|
+
if (status >= 400 || !data || typeof data !== "object" || Array.isArray(data)) {
|
|
186
|
+
logger.warn(`Expo getReceipts failed (${status})`, body?.errors ?? body);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
Object.assign(receipts, data);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error("Expo getReceipts request failed", err);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const id of ids) {
|
|
195
|
+
const receipt = receipts[id];
|
|
196
|
+
if (!receipt || typeof receipt !== "object") {
|
|
197
|
+
summary.pending += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
let token = this.tickets.get(id)?.[0] ?? "";
|
|
201
|
+
this.tickets.delete(id);
|
|
202
|
+
if (receipt.status === "ok") {
|
|
203
|
+
summary.ok += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
summary.errors += 1;
|
|
207
|
+
const details = receipt.details ?? {};
|
|
208
|
+
if (details.error === "DeviceNotRegistered") {
|
|
209
|
+
summary.unregistered += 1;
|
|
210
|
+
token = details.expoPushToken || token;
|
|
211
|
+
await this.dropToken(token);
|
|
212
|
+
} else logger.warn("Expo receipt error", receipt.message ?? details);
|
|
213
|
+
}
|
|
214
|
+
return summary;
|
|
215
|
+
}
|
|
216
|
+
/** Poll receipts every `intervalSeconds` in the background. `stopReceiptPoller()` / `aclose()` stop it. */
|
|
217
|
+
startReceiptPoller(intervalSeconds = 900) {
|
|
218
|
+
if (this.poller) return this.poller;
|
|
219
|
+
this.poller = setInterval(() => {
|
|
220
|
+
this.pollReceipts().catch((err) => logger.error("Expo receipt polling failed", err));
|
|
221
|
+
}, intervalSeconds * 1e3);
|
|
222
|
+
this.poller.unref();
|
|
223
|
+
return this.poller;
|
|
224
|
+
}
|
|
225
|
+
stopReceiptPoller() {
|
|
226
|
+
if (this.poller) clearInterval(this.poller);
|
|
227
|
+
this.poller = null;
|
|
228
|
+
}
|
|
229
|
+
/** `PushSender`: visible notification to every Expo token of `userId`. */
|
|
230
|
+
async send(userId, title, body, data, channel = "default", ttl = null) {
|
|
231
|
+
if (!this.registry) throw new Error("ExpoPushSender.send needs a registry (or use DevicePushRouter)");
|
|
232
|
+
const tokens = (await this.registry.devicesFor(userId)).map((d) => d.expo_push_token).filter((t) => !!t);
|
|
233
|
+
if (tokens.length) await this.sendMessages(tokens.map((t) => this.buildMessage(t, title, body, data, { channel, ttl })));
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
function defaultApnsSessionFactory(timeoutSeconds = 10) {
|
|
237
|
+
return (origin) => {
|
|
238
|
+
let session = null;
|
|
239
|
+
const connect = () => {
|
|
240
|
+
if (session && !session.closed && !session.destroyed) return session;
|
|
241
|
+
const s = http2.connect(origin);
|
|
242
|
+
s.on("error", () => {
|
|
243
|
+
if (session === s) session = null;
|
|
244
|
+
});
|
|
245
|
+
s.on("close", () => {
|
|
246
|
+
if (session === s) session = null;
|
|
247
|
+
});
|
|
248
|
+
s.unref();
|
|
249
|
+
session = s;
|
|
250
|
+
return s;
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
request(path, headers, body) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
let settled = false;
|
|
256
|
+
const done = (fn) => {
|
|
257
|
+
if (!settled) {
|
|
258
|
+
settled = true;
|
|
259
|
+
fn();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
const req = connect().request({ ":method": "POST", ":path": path, ...headers });
|
|
264
|
+
let status = 0;
|
|
265
|
+
const chunks = [];
|
|
266
|
+
req.setTimeout(timeoutSeconds * 1e3, () => {
|
|
267
|
+
req.close(http2.constants.NGHTTP2_CANCEL);
|
|
268
|
+
done(() => reject(new Error("APNs request timed out")));
|
|
269
|
+
});
|
|
270
|
+
req.on("response", (h) => {
|
|
271
|
+
status = Number(h[":status"] ?? 0);
|
|
272
|
+
});
|
|
273
|
+
req.on("data", (c) => chunks.push(c));
|
|
274
|
+
req.on("end", () => done(() => resolve({ status, body: Buffer.concat(chunks).toString("utf8") })));
|
|
275
|
+
req.on("error", (e) => done(() => reject(e)));
|
|
276
|
+
req.end(body);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
done(() => reject(err));
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
close() {
|
|
283
|
+
session?.close();
|
|
284
|
+
session = null;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
var ApnsVoipSender = class _ApnsVoipSender {
|
|
290
|
+
static INVALID_TOKEN_REASONS = /* @__PURE__ */ new Set(["BadDeviceToken", "Unregistered"]);
|
|
291
|
+
keyId;
|
|
292
|
+
teamId;
|
|
293
|
+
bundleId;
|
|
294
|
+
topic;
|
|
295
|
+
host;
|
|
296
|
+
registry;
|
|
297
|
+
expiration;
|
|
298
|
+
tokenRefresh;
|
|
299
|
+
/** Epoch seconds the cached JWT was signed (tests age it). */
|
|
300
|
+
jwtIssuedAt = 0;
|
|
301
|
+
pem;
|
|
302
|
+
jwt = null;
|
|
303
|
+
session = null;
|
|
304
|
+
sessionFactory;
|
|
305
|
+
constructor(opts) {
|
|
306
|
+
if (!(opts.key || opts.keyPath)) throw new Error("ApnsVoipSender needs key (PEM text of the .p8) or keyPath");
|
|
307
|
+
if (!(opts.keyId && opts.teamId && opts.bundleId)) throw new Error("ApnsVoipSender needs keyId, teamId and bundleId");
|
|
308
|
+
const refresh = opts.tokenRefresh ?? 50 * 60;
|
|
309
|
+
if (!(refresh > 0 && refresh < 3600)) throw new Error("tokenRefresh must be under 60 minutes");
|
|
310
|
+
this.pem = opts.key ?? readFileSync(opts.keyPath, "utf8");
|
|
311
|
+
this.keyId = opts.keyId;
|
|
312
|
+
this.teamId = opts.teamId;
|
|
313
|
+
this.bundleId = opts.bundleId;
|
|
314
|
+
this.topic = opts.bundleId.endsWith(".voip") ? opts.bundleId : `${opts.bundleId}.voip`;
|
|
315
|
+
this.host = opts.useSandbox ? APNS_SANDBOX_HOST : APNS_PRODUCTION_HOST;
|
|
316
|
+
this.registry = opts.registry ?? null;
|
|
317
|
+
this.expiration = opts.expiration ?? 45;
|
|
318
|
+
this.tokenRefresh = refresh;
|
|
319
|
+
this.sessionFactory = opts.sessionFactory ?? defaultApnsSessionFactory(opts.timeout ?? 10);
|
|
320
|
+
}
|
|
321
|
+
/** From APNS_KEY / APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, APNS_USE_SANDBOX. Null when not configured. */
|
|
322
|
+
static fromEnv(registry = null, overrides = {}, env = process.env) {
|
|
323
|
+
const key = env.APNS_KEY;
|
|
324
|
+
const keyPath = env.APNS_KEY_PATH;
|
|
325
|
+
const keyId = env.APNS_KEY_ID;
|
|
326
|
+
const teamId = env.APNS_TEAM_ID;
|
|
327
|
+
const bundleId = env.APNS_BUNDLE_ID;
|
|
328
|
+
if (!((key || keyPath) && keyId && teamId && bundleId)) return null;
|
|
329
|
+
const sandbox = ["1", "true", "yes"].includes((env.APNS_USE_SANDBOX ?? "").trim().toLowerCase());
|
|
330
|
+
return new _ApnsVoipSender({
|
|
331
|
+
key: key ? key.replace(/\\n/g, "\n") : void 0,
|
|
332
|
+
keyPath,
|
|
333
|
+
keyId,
|
|
334
|
+
teamId,
|
|
335
|
+
bundleId,
|
|
336
|
+
useSandbox: sandbox,
|
|
337
|
+
registry,
|
|
338
|
+
...overrides
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
getSession() {
|
|
342
|
+
if (!this.session) this.session = this.sessionFactory(this.host);
|
|
343
|
+
return this.session;
|
|
344
|
+
}
|
|
345
|
+
async aclose() {
|
|
346
|
+
const s = this.session;
|
|
347
|
+
this.session = null;
|
|
348
|
+
if (s) await s.close();
|
|
349
|
+
}
|
|
350
|
+
/** Cached ES256 provider JWT; re-signed after `tokenRefresh` seconds. */
|
|
351
|
+
async providerToken() {
|
|
352
|
+
const now = Date.now() / 1e3;
|
|
353
|
+
if (this.jwt === null || now - this.jwtIssuedAt >= this.tokenRefresh) {
|
|
354
|
+
const issued = Math.floor(now);
|
|
355
|
+
const key = await importPKCS8(this.pem, "ES256");
|
|
356
|
+
this.jwt = await new SignJWT({}).setProtectedHeader({ alg: "ES256", kid: this.keyId, typ: "JWT" }).setIssuer(this.teamId).setIssuedAt(issued).sign(key);
|
|
357
|
+
this.jwtIssuedAt = issued;
|
|
358
|
+
}
|
|
359
|
+
return this.jwt;
|
|
360
|
+
}
|
|
361
|
+
async headers() {
|
|
362
|
+
return {
|
|
363
|
+
authorization: `bearer ${await this.providerToken()}`,
|
|
364
|
+
"apns-push-type": "voip",
|
|
365
|
+
"apns-topic": this.topic,
|
|
366
|
+
"apns-priority": "10",
|
|
367
|
+
"apns-expiration": String(Math.floor(Date.now() / 1e3) + Math.trunc(this.expiration)),
|
|
368
|
+
"content-type": "application/json"
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/** POST /3/device/<token>. True when APNs accepted it. */
|
|
372
|
+
async sendVoip(token, payload) {
|
|
373
|
+
const body = JSON.stringify(payload);
|
|
374
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
375
|
+
let res;
|
|
376
|
+
try {
|
|
377
|
+
res = await this.getSession().request(`/3/device/${token}`, await this.headers(), body);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
logger.error("APNs request failed", err);
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
if (res.status === 200) return true;
|
|
383
|
+
let reason = "";
|
|
384
|
+
try {
|
|
385
|
+
reason = String(JSON.parse(res.body || "{}")?.reason ?? "");
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
if (res.status === 403 && reason === "ExpiredProviderToken" && attempt === 0) {
|
|
389
|
+
this.jwt = null;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (res.status === 410 || _ApnsVoipSender.INVALID_TOKEN_REASONS.has(reason)) {
|
|
393
|
+
if (this.registry) {
|
|
394
|
+
try {
|
|
395
|
+
await this.registry.removeToken(token, "voip");
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.error("Removing an invalid VoIP token failed", err);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} else logger.warn(`APNs VoIP push failed: ${res.status} ${reason}`);
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
var DevicePushRouter = class {
|
|
407
|
+
registry;
|
|
408
|
+
expo;
|
|
409
|
+
voip;
|
|
410
|
+
androidCallStyle;
|
|
411
|
+
constructor(opts) {
|
|
412
|
+
const style = opts.androidCallStyle ?? "notification";
|
|
413
|
+
if (style !== "notification" && style !== "data") throw new Error("androidCallStyle must be 'notification' or 'data'");
|
|
414
|
+
this.registry = opts.registry;
|
|
415
|
+
this.expo = opts.expo ?? new ExpoPushSender({ registry: opts.registry });
|
|
416
|
+
this.voip = opts.voip ?? null;
|
|
417
|
+
this.androidCallStyle = style;
|
|
418
|
+
if (!this.expo.registry) this.expo.registry = opts.registry;
|
|
419
|
+
if (this.voip && !this.voip.registry) this.voip.registry = opts.registry;
|
|
420
|
+
}
|
|
421
|
+
async aclose() {
|
|
422
|
+
await this.expo.aclose();
|
|
423
|
+
if (this.voip) await this.voip.aclose();
|
|
424
|
+
}
|
|
425
|
+
async send(userId, title, body, data, channel = "default", ttl = null) {
|
|
426
|
+
const devices = await this.registry.devicesFor(userId);
|
|
427
|
+
if (!devices.length) return;
|
|
428
|
+
const isCall = data.type === INCOMING_CALL;
|
|
429
|
+
const expoMessages = [];
|
|
430
|
+
const voipJobs = [];
|
|
431
|
+
const visible = (d) => this.expo.buildMessage(d.expo_push_token, title, body, data, { channel, ttl, priority: "high" });
|
|
432
|
+
for (const device of devices) {
|
|
433
|
+
if (!isCall) {
|
|
434
|
+
if (device.expo_push_token) expoMessages.push(visible(device));
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (device.platform === "ios" && device.voip_token && this.voip) {
|
|
438
|
+
const payload = { ...data };
|
|
439
|
+
if (data.call_id) payload.uuid = callkitUuid(String(data.call_id));
|
|
440
|
+
voipJobs.push([device, payload]);
|
|
441
|
+
} else if (!device.expo_push_token) {
|
|
442
|
+
continue;
|
|
443
|
+
} else if (device.platform === "android" && this.androidCallStyle === "data") {
|
|
444
|
+
expoMessages.push(this.expo.buildMessage(device.expo_push_token, null, null, data, { channel, ttl, priority: "high", dataOnly: true }));
|
|
445
|
+
} else {
|
|
446
|
+
expoMessages.push(visible(device));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (voipJobs.length) {
|
|
450
|
+
const results = await Promise.allSettled(voipJobs.map(([d, p]) => this.voip.sendVoip(d.voip_token, p)));
|
|
451
|
+
voipJobs.forEach(([device], i) => {
|
|
452
|
+
const r = results[i];
|
|
453
|
+
if (!(r.status === "fulfilled" && r.value === true) && device.expo_push_token) expoMessages.push(visible(device));
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (expoMessages.length) await this.expo.sendMessages(expoMessages);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
export {
|
|
460
|
+
APNS_PRODUCTION_HOST,
|
|
461
|
+
APNS_SANDBOX_HOST,
|
|
462
|
+
ApnsVoipSender,
|
|
463
|
+
DevicePushRouter,
|
|
464
|
+
DevicesFeature,
|
|
465
|
+
EXPO_PUSH_URL,
|
|
466
|
+
EXPO_RECEIPTS_URL,
|
|
467
|
+
ExpoPushSender,
|
|
468
|
+
PLATFORMS,
|
|
469
|
+
SqlDeviceRegistry,
|
|
470
|
+
buildDevicesRoutes,
|
|
471
|
+
callkitUuid,
|
|
472
|
+
defaultApnsSessionFactory,
|
|
473
|
+
devicesDdl
|
|
474
|
+
};
|
|
475
|
+
//# sourceMappingURL=push.js.map
|
package/dist/push.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/push.ts"],"sourcesContent":["/**\n * Push senders: Expo, APNs VoIP (PushKit) and a per-device router (PROTOCOL.md section 8).\n * Port of commskit.core.push. Uses the global `fetch` (Expo) and `node:http2` + `jose` (APNs); both are injectable\n * so tests never touch the network.\n *\n * const push = new DevicePushRouter({ registry, expo: new ExpoPushSender({ accessToken }), voip: ApnsVoipSender.fromEnv(), androidCallStyle: \"data\" });\n * new CommsHub({ ..., push });\n */\nimport { createHash } from \"node:crypto\";\nimport { readFileSync } from \"node:fs\";\nimport http2 from \"node:http2\";\nimport { SignJWT, importPKCS8 } from \"jose\";\nimport type { PushSender } from \"./adapters.js\";\nimport type { DeviceInfo, DeviceStore } from \"./devices.js\";\nimport { logger } from \"./logger.js\";\n\nexport const EXPO_PUSH_URL = \"https://exp.host/--/api/v2/push/send\";\nexport const EXPO_RECEIPTS_URL = \"https://exp.host/--/api/v2/push/getReceipts\";\nexport const APNS_PRODUCTION_HOST = \"https://api.push.apple.com\";\nexport const APNS_SANDBOX_HOST = \"https://api.sandbox.push.apple.com\";\nconst INCOMING_CALL = \"incoming_call\";\n\n// ------------------------------------------------------------------ callkit\n\nconst NAMESPACE_URL = Buffer.from(\"6ba7b8119dad11d180b400c04fd430c8\", \"hex\");\n\n/** CallKit UUID for a call id: UUIDv5(NAMESPACE_URL, `commskit:call:<callId>`). Shared with the mobile kit. */\nexport function callkitUuid(callId: string): string {\n const h = createHash(\"sha1\").update(NAMESPACE_URL).update(`commskit:call:${callId}`, \"utf8\").digest().subarray(0, 16);\n h[6] = (h[6]! & 0x0f) | 0x50;\n h[8] = (h[8]! & 0x3f) | 0x80;\n const x = h.toString(\"hex\");\n return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`;\n}\n\n// ---------------------------------------------------------------------- Expo\n\nexport type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{\n status: number;\n text(): Promise<string>;\n}>;\n\nexport interface ExpoTicket {\n status?: string;\n id?: string;\n message?: string;\n details?: { error?: string; expoPushToken?: string; [k: string]: unknown };\n [k: string]: unknown;\n}\n\nexport type ExpoMessage = Record<string, any>;\n\nexport interface ExpoPushSenderOptions {\n registry?: DeviceStore | null;\n accessToken?: string | null;\n /** Defaults to the global `fetch`. */\n fetch?: FetchLike;\n url?: string;\n batchSize?: number;\n /** Seconds. */\n timeout?: number;\n sound?: string | null;\n receiptsUrl?: string;\n receiptBatchSize?: number;\n maxPendingTickets?: number;\n /** Seconds. */\n receiptTtl?: number;\n}\n\nexport interface BuildMessageOptions {\n channel?: string | null;\n ttl?: number | null;\n priority?: \"default\" | \"normal\" | \"high\";\n /** `false` (default) = the sender's default sound. */\n sound?: string | null | false;\n dataOnly?: boolean;\n}\n\n/**\n * Expo push client; also a `PushSender` on its own (needs `registry`). `DeviceNotRegistered` tickets remove that\n * token; accepted ticket ids are remembered (up to `maxPendingTickets`, `receiptTtl`) and `pollReceipts()`\n * checks them later (`startReceiptPoller(900)` runs it in the background).\n */\nexport class ExpoPushSender implements PushSender {\n registry: DeviceStore | null;\n accessToken: string | null;\n url: string;\n batchSize: number;\n timeout: number;\n sound: string | null;\n receiptsUrl: string;\n receiptBatchSize: number;\n maxPendingTickets: number;\n receiptTtl: number;\n private fetchImpl?: FetchLike;\n private tickets = new Map<string, [string, number]>(); // ticket id -> [token, sent at (ms)]\n private poller: NodeJS.Timeout | null = null;\n\n constructor(opts: ExpoPushSenderOptions = {}) {\n const batchSize = opts.batchSize ?? 100;\n const receiptBatchSize = opts.receiptBatchSize ?? 1000;\n if (!(batchSize >= 1 && batchSize <= 100)) throw new Error(\"Expo accepts at most 100 messages per request\");\n if (!(receiptBatchSize >= 1 && receiptBatchSize <= 1000)) throw new Error(\"Expo accepts at most 1000 receipt ids per request\");\n this.registry = opts.registry ?? null;\n this.accessToken = opts.accessToken ?? null;\n this.fetchImpl = opts.fetch;\n this.url = opts.url ?? EXPO_PUSH_URL;\n this.batchSize = batchSize;\n this.timeout = opts.timeout ?? 10;\n this.sound = opts.sound === undefined ? \"default\" : opts.sound;\n this.receiptsUrl = opts.receiptsUrl ?? EXPO_RECEIPTS_URL;\n this.receiptBatchSize = receiptBatchSize;\n this.maxPendingTickets = opts.maxPendingTickets ?? 50_000;\n this.receiptTtl = opts.receiptTtl ?? 24 * 3600;\n }\n\n private headers(): Record<string, string> {\n const h: Record<string, string> = {\n Accept: \"application/json\",\n \"Accept-Encoding\": \"gzip, deflate\",\n \"Content-Type\": \"application/json\",\n };\n if (this.accessToken) h.Authorization = `Bearer ${this.accessToken}`;\n return h;\n }\n\n private async post(url: string, payload: unknown): Promise<{ status: number; body: any }> {\n const f: FetchLike = this.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);\n const res = await f(url, {\n method: \"POST\",\n headers: this.headers(),\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(this.timeout * 1000),\n });\n const text = await res.text();\n let body: any = {};\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = {};\n }\n }\n return { status: res.status, body };\n }\n\n /** Nothing to release by default (global fetch); stops the receipt poller. */\n async aclose(): Promise<void> {\n this.stopReceiptPoller();\n }\n\n /** One Expo message. `dataOnly` omits title/body/sound: Android hands it to the app instead of displaying it. */\n buildMessage(token: string, title: string | null, body: string | null, data: Record<string, unknown>, opts: BuildMessageOptions = {}): ExpoMessage {\n const { channel = \"default\", ttl = null, priority = \"high\", sound = false, dataOnly = false } = opts;\n const message: ExpoMessage = { to: token, data: { ...data }, priority };\n if (!dataOnly) {\n message.title = title;\n message.body = body;\n const chosen = sound === false ? this.sound : sound;\n if (chosen) message.sound = chosen;\n } else {\n message._contentAvailable = true;\n }\n if (channel) message.channelId = channel;\n if (ttl !== null && ttl !== undefined) message.ttl = Math.trunc(ttl);\n return message;\n }\n\n /** POST in batches of `batchSize`. One ticket per message, in order; a failed batch yields error tickets. */\n async sendMessages(messages: readonly ExpoMessage[]): Promise<ExpoTicket[]> {\n const tickets: ExpoTicket[] = [];\n for (let start = 0; start < messages.length; start += this.batchSize) {\n const batch = messages.slice(start, start + this.batchSize).map((m) => ({ ...m }));\n let batchTickets: ExpoTicket[];\n try {\n const { status, body } = await this.post(this.url, batch);\n if (status >= 400 || body?.errors) logger.warn(`Expo push batch failed (${status})`, body?.errors ?? body);\n const data = body && typeof body === \"object\" ? body.data : null;\n if (Array.isArray(data) && data.length === batch.length) {\n batchTickets = data.map((t) => (t && typeof t === \"object\" ? t : {}));\n } else {\n batchTickets = batch.map(() => ({ status: \"error\", message: `HTTP ${status}` }));\n }\n } catch (err) {\n logger.error(\"Expo push request failed\", err);\n batchTickets = batch.map(() => ({ status: \"error\", message: String((err as Error)?.message ?? err) }));\n }\n await this.handleTickets(batch, batchTickets);\n this.rememberTickets(batch, batchTickets);\n tickets.push(...batchTickets);\n }\n return tickets;\n }\n\n private async dropToken(token: unknown): Promise<void> {\n if (!token || !this.registry) return;\n try {\n await this.registry.removeToken(String(token), \"expo\");\n } catch (err) {\n logger.error(\"Removing an unregistered Expo token failed\", err);\n }\n }\n\n private async handleTickets(batch: readonly ExpoMessage[], tickets: readonly ExpoTicket[]): Promise<void> {\n for (let i = 0; i < batch.length; i++) {\n const ticket = tickets[i]!;\n if (ticket.status !== \"error\") continue;\n const details = ticket.details ?? {};\n if (details.error === \"DeviceNotRegistered\") await this.dropToken(details.expoPushToken || batch[i]!.to);\n else logger.warn(\"Expo push error\", ticket.message ?? details);\n }\n }\n\n private rememberTickets(batch: readonly ExpoMessage[], tickets: readonly ExpoTicket[]): void {\n const now = Date.now();\n for (let i = 0; i < batch.length; i++) {\n const t = tickets[i]!;\n if (t.status === \"ok\" && t.id && batch[i]!.to) {\n this.tickets.delete(String(t.id));\n this.tickets.set(String(t.id), [String(batch[i]!.to), now]);\n }\n }\n while (this.tickets.size > this.maxPendingTickets) this.tickets.delete(this.tickets.keys().next().value as string);\n }\n\n /** `{ticketId: expoPushToken}` still waiting for a receipt. */\n get pendingTickets(): Record<string, string> {\n return Object.fromEntries([...this.tickets].map(([id, [token]]) => [id, token]));\n }\n\n /**\n * Fetch receipts for the remembered tickets (`getReceipts`, at most `receiptBatchSize` ids per request).\n * `DeviceNotRegistered` removes the token. Tickets with a receipt are forgotten; others stay pending until\n * `receiptTtl`. Returns `{checked, ok, errors, unregistered, pending}`.\n */\n async pollReceipts(): Promise<{ checked: number; ok: number; errors: number; unregistered: number; pending: number }> {\n const now = Date.now();\n for (const [id, [, at]] of [...this.tickets]) if (now - at > this.receiptTtl * 1000) this.tickets.delete(id);\n const ids = [...this.tickets.keys()];\n const summary = { checked: ids.length, ok: 0, errors: 0, unregistered: 0, pending: 0 };\n if (!ids.length) return summary;\n const receipts: Record<string, any> = {};\n for (let start = 0; start < ids.length; start += this.receiptBatchSize) {\n const chunk = ids.slice(start, start + this.receiptBatchSize);\n try {\n const { status, body } = await this.post(this.receiptsUrl, { ids: chunk });\n const data = body && typeof body === \"object\" ? body.data : null;\n if (status >= 400 || !data || typeof data !== \"object\" || Array.isArray(data)) {\n logger.warn(`Expo getReceipts failed (${status})`, body?.errors ?? body);\n continue;\n }\n Object.assign(receipts, data);\n } catch (err) {\n logger.error(\"Expo getReceipts request failed\", err);\n }\n }\n for (const id of ids) {\n const receipt = receipts[id];\n if (!receipt || typeof receipt !== \"object\") {\n summary.pending += 1;\n continue;\n }\n let token: unknown = this.tickets.get(id)?.[0] ?? \"\";\n this.tickets.delete(id);\n if (receipt.status === \"ok\") {\n summary.ok += 1;\n continue;\n }\n summary.errors += 1;\n const details = receipt.details ?? {};\n if (details.error === \"DeviceNotRegistered\") {\n summary.unregistered += 1;\n token = details.expoPushToken || token;\n await this.dropToken(token);\n } else logger.warn(\"Expo receipt error\", receipt.message ?? details);\n }\n return summary;\n }\n\n /** Poll receipts every `intervalSeconds` in the background. `stopReceiptPoller()` / `aclose()` stop it. */\n startReceiptPoller(intervalSeconds = 900): NodeJS.Timeout {\n if (this.poller) return this.poller;\n this.poller = setInterval(() => {\n this.pollReceipts().catch((err) => logger.error(\"Expo receipt polling failed\", err));\n }, intervalSeconds * 1000);\n this.poller.unref();\n return this.poller;\n }\n\n stopReceiptPoller(): void {\n if (this.poller) clearInterval(this.poller);\n this.poller = null;\n }\n\n /** `PushSender`: visible notification to every Expo token of `userId`. */\n async send(userId: string, title: string, body: string, data: Record<string, unknown>, channel = \"default\", ttl: number | null = null): Promise<void> {\n if (!this.registry) throw new Error(\"ExpoPushSender.send needs a registry (or use DevicePushRouter)\");\n const tokens = (await this.registry.devicesFor(userId)).map((d) => d.expo_push_token).filter((t): t is string => !!t);\n if (tokens.length) await this.sendMessages(tokens.map((t) => this.buildMessage(t, title, body, data, { channel, ttl })));\n }\n}\n\n// ------------------------------------------------------------- APNs (VoIP)\n\nexport interface ApnsResponse {\n status: number;\n body: string;\n}\n\n/** One HTTP/2 connection to APNs (injectable for tests). */\nexport interface ApnsSession {\n request(path: string, headers: Record<string, string>, body: string): Promise<ApnsResponse>;\n close(): void | Promise<void>;\n}\nexport type ApnsSessionFactory = (origin: string) => ApnsSession;\n\n/** Default: a lazily connected, reconnecting `node:http2` session. */\nexport function defaultApnsSessionFactory(timeoutSeconds = 10): ApnsSessionFactory {\n return (origin) => {\n let session: http2.ClientHttp2Session | null = null;\n const connect = (): http2.ClientHttp2Session => {\n if (session && !session.closed && !session.destroyed) return session;\n const s = http2.connect(origin);\n s.on(\"error\", () => {\n if (session === s) session = null;\n });\n s.on(\"close\", () => {\n if (session === s) session = null;\n });\n s.unref();\n session = s;\n return s;\n };\n return {\n request(path, headers, body) {\n return new Promise<ApnsResponse>((resolve, reject) => {\n let settled = false;\n const done = (fn: () => void) => {\n if (!settled) {\n settled = true;\n fn();\n }\n };\n try {\n const req = connect().request({ \":method\": \"POST\", \":path\": path, ...headers });\n let status = 0;\n const chunks: Buffer[] = [];\n req.setTimeout(timeoutSeconds * 1000, () => {\n req.close(http2.constants.NGHTTP2_CANCEL);\n done(() => reject(new Error(\"APNs request timed out\")));\n });\n req.on(\"response\", (h) => {\n status = Number(h[\":status\"] ?? 0);\n });\n req.on(\"data\", (c: Buffer) => chunks.push(c));\n req.on(\"end\", () => done(() => resolve({ status, body: Buffer.concat(chunks).toString(\"utf8\") })));\n req.on(\"error\", (e) => done(() => reject(e)));\n req.end(body);\n } catch (err) {\n done(() => reject(err));\n }\n });\n },\n close() {\n session?.close();\n session = null;\n },\n };\n };\n}\n\nexport interface ApnsVoipSenderOptions {\n /** PEM text of the .p8 key. */\n key?: string;\n keyPath?: string;\n keyId: string;\n teamId: string;\n bundleId: string;\n useSandbox?: boolean;\n registry?: DeviceStore | null;\n /** Seconds a push may wait at APNs (apns-expiration = now + this). */\n expiration?: number;\n /** Seconds before the provider JWT is re-signed (< 3600, as Apple requires). */\n tokenRefresh?: number;\n sessionFactory?: ApnsSessionFactory;\n /** Seconds. */\n timeout?: number;\n}\n\n/**\n * iOS PushKit (VoIP) pushes over HTTP/2 with token-based (.p8) auth. Headers: `apns-push-type: voip`,\n * `apns-topic: <bundle>.voip`, `apns-priority: 10`, `apns-expiration: now + expiration`. The provider JWT (ES256,\n * kid = key id, iss = team id) is cached and refreshed every `tokenRefresh` seconds. 410 / BadDeviceToken /\n * Unregistered remove the token; ExpiredProviderToken re-signs once and retries.\n */\nexport class ApnsVoipSender {\n static readonly INVALID_TOKEN_REASONS = new Set([\"BadDeviceToken\", \"Unregistered\"]);\n readonly keyId: string;\n readonly teamId: string;\n readonly bundleId: string;\n readonly topic: string;\n readonly host: string;\n registry: DeviceStore | null;\n expiration: number;\n tokenRefresh: number;\n /** Epoch seconds the cached JWT was signed (tests age it). */\n jwtIssuedAt = 0;\n private pem: string;\n private jwt: string | null = null;\n private session: ApnsSession | null = null;\n private sessionFactory: ApnsSessionFactory;\n\n constructor(opts: ApnsVoipSenderOptions) {\n if (!(opts.key || opts.keyPath)) throw new Error(\"ApnsVoipSender needs key (PEM text of the .p8) or keyPath\");\n if (!(opts.keyId && opts.teamId && opts.bundleId)) throw new Error(\"ApnsVoipSender needs keyId, teamId and bundleId\");\n const refresh = opts.tokenRefresh ?? 50 * 60;\n if (!(refresh > 0 && refresh < 3600)) throw new Error(\"tokenRefresh must be under 60 minutes\");\n this.pem = opts.key ?? readFileSync(opts.keyPath!, \"utf8\");\n this.keyId = opts.keyId;\n this.teamId = opts.teamId;\n this.bundleId = opts.bundleId;\n this.topic = opts.bundleId.endsWith(\".voip\") ? opts.bundleId : `${opts.bundleId}.voip`;\n this.host = opts.useSandbox ? APNS_SANDBOX_HOST : APNS_PRODUCTION_HOST;\n this.registry = opts.registry ?? null;\n this.expiration = opts.expiration ?? 45;\n this.tokenRefresh = refresh;\n this.sessionFactory = opts.sessionFactory ?? defaultApnsSessionFactory(opts.timeout ?? 10);\n }\n\n /** From APNS_KEY / APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, APNS_USE_SANDBOX. Null when not configured. */\n static fromEnv(registry: DeviceStore | null = null, overrides: Partial<ApnsVoipSenderOptions> = {}, env: NodeJS.ProcessEnv = process.env): ApnsVoipSender | null {\n const key = env.APNS_KEY;\n const keyPath = env.APNS_KEY_PATH;\n const keyId = env.APNS_KEY_ID;\n const teamId = env.APNS_TEAM_ID;\n const bundleId = env.APNS_BUNDLE_ID;\n if (!((key || keyPath) && keyId && teamId && bundleId)) return null;\n const sandbox = [\"1\", \"true\", \"yes\"].includes((env.APNS_USE_SANDBOX ?? \"\").trim().toLowerCase());\n return new ApnsVoipSender({\n key: key ? key.replace(/\\\\n/g, \"\\n\") : undefined,\n keyPath,\n keyId,\n teamId,\n bundleId,\n useSandbox: sandbox,\n registry,\n ...overrides,\n });\n }\n\n private getSession(): ApnsSession {\n if (!this.session) this.session = this.sessionFactory(this.host);\n return this.session;\n }\n\n async aclose(): Promise<void> {\n const s = this.session;\n this.session = null;\n if (s) await s.close();\n }\n\n /** Cached ES256 provider JWT; re-signed after `tokenRefresh` seconds. */\n async providerToken(): Promise<string> {\n const now = Date.now() / 1000;\n if (this.jwt === null || now - this.jwtIssuedAt >= this.tokenRefresh) {\n const issued = Math.floor(now);\n const key = await importPKCS8(this.pem, \"ES256\");\n this.jwt = await new SignJWT({})\n .setProtectedHeader({ alg: \"ES256\", kid: this.keyId, typ: \"JWT\" })\n .setIssuer(this.teamId)\n .setIssuedAt(issued)\n .sign(key);\n this.jwtIssuedAt = issued;\n }\n return this.jwt;\n }\n\n async headers(): Promise<Record<string, string>> {\n return {\n authorization: `bearer ${await this.providerToken()}`,\n \"apns-push-type\": \"voip\",\n \"apns-topic\": this.topic,\n \"apns-priority\": \"10\",\n \"apns-expiration\": String(Math.floor(Date.now() / 1000) + Math.trunc(this.expiration)),\n \"content-type\": \"application/json\",\n };\n }\n\n /** POST /3/device/<token>. True when APNs accepted it. */\n async sendVoip(token: string, payload: Record<string, unknown>): Promise<boolean> {\n const body = JSON.stringify(payload);\n for (let attempt = 0; attempt < 2; attempt++) {\n let res: ApnsResponse;\n try {\n res = await this.getSession().request(`/3/device/${token}`, await this.headers(), body);\n } catch (err) {\n logger.error(\"APNs request failed\", err);\n return false;\n }\n if (res.status === 200) return true;\n let reason = \"\";\n try {\n reason = String(JSON.parse(res.body || \"{}\")?.reason ?? \"\");\n } catch {\n /* not JSON */\n }\n if (res.status === 403 && reason === \"ExpiredProviderToken\" && attempt === 0) {\n this.jwt = null; // clock skew or a long-lived process: re-sign once and retry\n continue;\n }\n if (res.status === 410 || ApnsVoipSender.INVALID_TOKEN_REASONS.has(reason)) {\n if (this.registry) {\n try {\n await this.registry.removeToken(token, \"voip\");\n } catch (err) {\n logger.error(\"Removing an invalid VoIP token failed\", err);\n }\n }\n } else logger.warn(`APNs VoIP push failed: ${res.status} ${reason}`);\n return false;\n }\n return false;\n }\n}\n\n// ------------------------------------------------------------------ router\n\nexport interface DevicePushRouterOptions {\n registry: DeviceStore;\n expo?: ExpoPushSender;\n voip?: ApnsVoipSender | null;\n androidCallStyle?: \"notification\" | \"data\";\n}\n\n/**\n * `PushSender` that picks the transport per device (PROTOCOL.md section 8).\n * - `data.type === \"incoming_call\"`: iOS device with a voip token and a `voip` sender -> APNs VoIP with\n * `uuid = callkitUuid(call_id)` (if APNs refuses and the device has an Expo token: visible Expo instead);\n * Android + `androidCallStyle \"data\"` -> Expo data-only; anything else with an Expo token -> visible Expo, priority high.\n * - everything else (chat, missed call, host pushes) -> visible Expo notification.\n */\nexport class DevicePushRouter implements PushSender {\n readonly registry: DeviceStore;\n readonly expo: ExpoPushSender;\n readonly voip: ApnsVoipSender | null;\n readonly androidCallStyle: \"notification\" | \"data\";\n\n constructor(opts: DevicePushRouterOptions) {\n const style = opts.androidCallStyle ?? \"notification\";\n if (style !== \"notification\" && style !== \"data\") throw new Error(\"androidCallStyle must be 'notification' or 'data'\");\n this.registry = opts.registry;\n this.expo = opts.expo ?? new ExpoPushSender({ registry: opts.registry });\n this.voip = opts.voip ?? null;\n this.androidCallStyle = style;\n if (!this.expo.registry) this.expo.registry = opts.registry;\n if (this.voip && !this.voip.registry) this.voip.registry = opts.registry;\n }\n\n async aclose(): Promise<void> {\n await this.expo.aclose();\n if (this.voip) await this.voip.aclose();\n }\n\n async send(userId: string, title: string, body: string, data: Record<string, unknown>, channel = \"default\", ttl: number | null = null): Promise<void> {\n const devices = await this.registry.devicesFor(userId);\n if (!devices.length) return;\n const isCall = data.type === INCOMING_CALL;\n const expoMessages: ExpoMessage[] = [];\n const voipJobs: Array<[DeviceInfo, Record<string, unknown>]> = [];\n const visible = (d: DeviceInfo): ExpoMessage =>\n this.expo.buildMessage(d.expo_push_token!, title, body, data, { channel, ttl, priority: \"high\" });\n\n for (const device of devices) {\n if (!isCall) {\n if (device.expo_push_token) expoMessages.push(visible(device));\n continue;\n }\n if (device.platform === \"ios\" && device.voip_token && this.voip) {\n const payload = { ...data };\n if (data.call_id) payload.uuid = callkitUuid(String(data.call_id));\n voipJobs.push([device, payload]);\n } else if (!device.expo_push_token) {\n continue;\n } else if (device.platform === \"android\" && this.androidCallStyle === \"data\") {\n expoMessages.push(this.expo.buildMessage(device.expo_push_token, null, null, data, { channel, ttl, priority: \"high\", dataOnly: true }));\n } else {\n expoMessages.push(visible(device));\n }\n }\n\n if (voipJobs.length) {\n const results = await Promise.allSettled(voipJobs.map(([d, p]) => this.voip!.sendVoip(d.voip_token!, p)));\n voipJobs.forEach(([device], i) => {\n const r = results[i]!;\n if (!(r.status === \"fulfilled\" && r.value === true) && device.expo_push_token) expoMessages.push(visible(device));\n });\n }\n if (expoMessages.length) await this.expo.sendMessages(expoMessages);\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/redis.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { P as PresenceBridge, D as Deliver, L as Limiter, R as RateLimit } from './connections-CGEPiZPF.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional Redis presence + cross-worker delivery (port of commskit.core.redis_bridge) and the shared
|
|
5
|
+
* token-bucket limiter (port of commskit.core.ratelimit.RedisRateLimiter). Needs `ioredis` (optional peer
|
|
6
|
+
* dependency, imported lazily) unless a client is injected.
|
|
7
|
+
*
|
|
8
|
+
* Key names match the Python kit and matrimonial-backend-v2, so Python and Node workers interoperate:
|
|
9
|
+
* `presence:{uid}` (hash serverId -> heartbeat epoch seconds), `presence_away:{uid}`, pub/sub `user_channel:{uid}`
|
|
10
|
+
* carrying `{"origin","data"}`, `room_channel:{room}` (same envelope) and `came_online:{uid}` (SET NX PX).
|
|
11
|
+
*
|
|
12
|
+
* Every Redis failure is logged and degrades to "not connected" (local-only); it never throws.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
declare const USER_CHANNEL_PREFIX = "user_channel:";
|
|
16
|
+
declare const USER_CHANNEL_PATTERN = "user_channel:*";
|
|
17
|
+
declare const PRESENCE_KEY_PREFIX = "presence:";
|
|
18
|
+
declare const AWAY_KEY_PREFIX = "presence_away:";
|
|
19
|
+
declare const ROOM_CHANNEL_PREFIX = "room_channel:";
|
|
20
|
+
declare const ROOM_CHANNEL_PATTERN = "room_channel:*";
|
|
21
|
+
declare const CAME_ONLINE_KEY_PREFIX = "came_online:";
|
|
22
|
+
/** The slice of an `ioredis` client the bridge uses. */
|
|
23
|
+
interface RedisClientLike {
|
|
24
|
+
ping(): Promise<unknown>;
|
|
25
|
+
hset(key: string, field: string, value: string | number): Promise<unknown>;
|
|
26
|
+
expire(key: string, seconds: number): Promise<unknown>;
|
|
27
|
+
hdel(key: string, field: string): Promise<unknown>;
|
|
28
|
+
hgetall(key: string): Promise<Record<string, string>>;
|
|
29
|
+
set(key: string, value: string | number, ...args: Array<string | number>): Promise<unknown>;
|
|
30
|
+
del(key: string): Promise<unknown>;
|
|
31
|
+
exists(key: string): Promise<number>;
|
|
32
|
+
publish(channel: string, message: string): Promise<unknown>;
|
|
33
|
+
eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;
|
|
34
|
+
/** A second connection for the subscriber. */
|
|
35
|
+
duplicate(): RedisSubscriberLike;
|
|
36
|
+
connect?(): Promise<unknown>;
|
|
37
|
+
quit?(): Promise<unknown>;
|
|
38
|
+
disconnect?(): void;
|
|
39
|
+
on?(event: string, listener: (...args: any[]) => void): unknown;
|
|
40
|
+
}
|
|
41
|
+
interface RedisSubscriberLike {
|
|
42
|
+
psubscribe(...patterns: string[]): Promise<unknown>;
|
|
43
|
+
on(event: "pmessage", listener: (pattern: string, channel: string, message: string) => void): unknown;
|
|
44
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
45
|
+
connect?(): Promise<unknown>;
|
|
46
|
+
quit?(): Promise<unknown>;
|
|
47
|
+
disconnect?(): void;
|
|
48
|
+
}
|
|
49
|
+
type RoomDeliver = (room: string, envelope: any) => Promise<void>;
|
|
50
|
+
interface RedisBridgeOptions {
|
|
51
|
+
url?: string;
|
|
52
|
+
/** An already-created ioredis-compatible client (tests, shared connections). */
|
|
53
|
+
client?: RedisClientLike;
|
|
54
|
+
heartbeatSeconds?: number;
|
|
55
|
+
staleSeconds?: number;
|
|
56
|
+
presenceTtlSeconds?: number;
|
|
57
|
+
awayTtlSeconds?: number;
|
|
58
|
+
}
|
|
59
|
+
declare class RedisBridge implements PresenceBridge {
|
|
60
|
+
client: RedisClientLike | null;
|
|
61
|
+
heartbeatSeconds: number;
|
|
62
|
+
staleSeconds: number;
|
|
63
|
+
presenceTtlSeconds: number;
|
|
64
|
+
awayTtlSeconds: number;
|
|
65
|
+
private url?;
|
|
66
|
+
private sub;
|
|
67
|
+
private deliver;
|
|
68
|
+
private roomDeliver;
|
|
69
|
+
constructor(opts?: RedisBridgeOptions);
|
|
70
|
+
setRoomDeliver(fn: RoomDeliver): void;
|
|
71
|
+
start(deliver: Deliver, _serverId: string): Promise<void>;
|
|
72
|
+
stop(): Promise<void>;
|
|
73
|
+
private consume;
|
|
74
|
+
setOnline(userId: string, serverId: string): Promise<void>;
|
|
75
|
+
setOffline(userId: string, serverId: string): Promise<void>;
|
|
76
|
+
setAway(userId: string, away: boolean): Promise<void>;
|
|
77
|
+
getUserServers(userId: string): Promise<string[]>;
|
|
78
|
+
isOnline(userId: string): Promise<boolean>;
|
|
79
|
+
getOnlineUsers(userIds: string[]): Promise<Set<string>>;
|
|
80
|
+
publish(userId: string, payload: unknown, origin: string): Promise<void>;
|
|
81
|
+
publishRoom(room: string, payload: unknown, origin: string): Promise<void>;
|
|
82
|
+
/** True for exactly one caller until the key expires or is released (`SET NX PX`). Without Redis every caller wins. */
|
|
83
|
+
claimCameOnline(userId: string, ttlSeconds: number): Promise<boolean>;
|
|
84
|
+
releaseCameOnline(userId: string): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
declare const TOKEN_BUCKET_LUA = "\nlocal cap = tonumber(ARGV[1])\nlocal rate = tonumber(ARGV[2])\nlocal now = tonumber(ARGV[3])\nlocal cost = tonumber(ARGV[4])\nlocal v = redis.call('HMGET', KEYS[1], 't', 'ts')\nlocal tokens = tonumber(v[1])\nlocal ts = tonumber(v[2])\nif tokens == nil or ts == nil then tokens = cap; ts = now end\nif now > ts then tokens = math.min(cap, tokens + (now - ts) * rate) end\nlocal wait = 0\nif tokens >= cost then tokens = tokens - cost else wait = (cost - tokens) / rate end\nredis.call('HSET', KEYS[1], 't', tostring(tokens), 'ts', tostring(math.max(now, ts)))\nredis.call('PEXPIRE', KEYS[1], tonumber(ARGV[5]))\nreturn tostring(wait)\n";
|
|
87
|
+
/**
|
|
88
|
+
* Token buckets in Redis, shared by all workers. `source` is a `RedisBridge` (its `client` is read on every call, so the
|
|
89
|
+
* limiter can be built before `hub.connections.start()`) or an ioredis-compatible client. Any Redis error, or no
|
|
90
|
+
* client yet, falls back to `fallback` (in-memory by default).
|
|
91
|
+
*/
|
|
92
|
+
declare class RedisRateLimiter implements Limiter {
|
|
93
|
+
private source;
|
|
94
|
+
prefix: string;
|
|
95
|
+
fallback: Limiter;
|
|
96
|
+
constructor(source: {
|
|
97
|
+
client?: RedisClientLike | null;
|
|
98
|
+
} | RedisClientLike, prefix?: string, fallback?: Limiter);
|
|
99
|
+
private client;
|
|
100
|
+
hit(key: string, limit: RateLimit | null | undefined, cost?: number): Promise<number>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { AWAY_KEY_PREFIX, CAME_ONLINE_KEY_PREFIX, PRESENCE_KEY_PREFIX, ROOM_CHANNEL_PATTERN, ROOM_CHANNEL_PREFIX, RedisBridge, type RedisBridgeOptions, type RedisClientLike, RedisRateLimiter, type RedisSubscriberLike, type RoomDeliver, TOKEN_BUCKET_LUA, USER_CHANNEL_PATTERN, USER_CHANNEL_PREFIX };
|