@vex-chat/spire 2.3.4 → 2.5.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/dist/CallManager.d.ts +38 -0
- package/dist/CallManager.js +243 -0
- package/dist/CallManager.js.map +1 -0
- package/dist/ClientManager.d.ts +3 -1
- package/dist/ClientManager.js +31 -3
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +3 -2
- package/dist/Database.js +7 -1
- package/dist/Database.js.map +1 -1
- package/dist/IceServers.d.ts +7 -0
- package/dist/IceServers.js +143 -0
- package/dist/IceServers.js.map +1 -0
- package/dist/NotificationService.d.ts +2 -0
- package/dist/NotificationService.js +325 -4
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +37 -6
- package/dist/Spire.js.map +1 -1
- package/dist/server/callWake.d.ts +13 -0
- package/dist/server/callWake.js +18 -0
- package/dist/server/callWake.js.map +1 -0
- package/package.json +6 -5
- package/src/CallManager.ts +370 -0
- package/src/ClientManager.ts +49 -10
- package/src/Database.ts +12 -3
- package/src/IceServers.ts +185 -0
- package/src/NotificationService.ts +430 -4
- package/src/Spire.ts +73 -21
- package/src/__tests__/CallManager.spec.ts +208 -0
- package/src/__tests__/IceServers.spec.ts +151 -0
- package/src/__tests__/notifyFanout.spec.ts +136 -0
- package/src/server/callWake.ts +30 -0
package/src/ClientManager.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { CallManager } from "./CallManager.ts";
|
|
7
8
|
import type { Database } from "./Database.ts";
|
|
8
9
|
import type {
|
|
9
10
|
BaseMsg,
|
|
@@ -27,6 +28,7 @@ import { MailWSSchema, SocketAuthErrors } from "@vex-chat/types";
|
|
|
27
28
|
|
|
28
29
|
import { parse as uuidParse, validate as uuidValidate } from "uuid";
|
|
29
30
|
|
|
31
|
+
import { callWakeDispatchData } from "./server/callWake.ts";
|
|
30
32
|
import { validateMailIngress } from "./server/mailIngress.ts";
|
|
31
33
|
import { TOKEN_EXPIRY } from "./Spire.ts";
|
|
32
34
|
import { createUint8UUID } from "./utils/createUint8UUID.ts";
|
|
@@ -44,7 +46,9 @@ function emptyHeader() {
|
|
|
44
46
|
return new Uint8Array(32);
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
// WebRTC SDP offers/answers are commonly several KB before encryption/packing.
|
|
50
|
+
// Keep this well below HTTP body limits while allowing first-party call signals.
|
|
51
|
+
const MAX_MSG_SIZE = 64 * 1024;
|
|
48
52
|
|
|
49
53
|
// How many ping cycles in a row are allowed to go without a pong
|
|
50
54
|
// before we declare the connection dead. With a 5s ping interval
|
|
@@ -68,6 +72,7 @@ const PING_INTERVAL_MS = 5000;
|
|
|
68
72
|
export class ClientManager extends EventEmitter {
|
|
69
73
|
private alive: boolean = true;
|
|
70
74
|
private authed: boolean = false;
|
|
75
|
+
private callManager: CallManager;
|
|
71
76
|
private challengeID: Uint8Array = createUint8UUID();
|
|
72
77
|
private conn: WebSocket;
|
|
73
78
|
private db: Database;
|
|
@@ -89,6 +94,7 @@ export class ClientManager extends EventEmitter {
|
|
|
89
94
|
constructor(
|
|
90
95
|
ws: WebSocket,
|
|
91
96
|
db: Database,
|
|
97
|
+
callManager: CallManager,
|
|
92
98
|
notify: (
|
|
93
99
|
userID: string,
|
|
94
100
|
event: string,
|
|
@@ -103,6 +109,7 @@ export class ClientManager extends EventEmitter {
|
|
|
103
109
|
super();
|
|
104
110
|
this.conn = ws;
|
|
105
111
|
this.db = db;
|
|
112
|
+
this.callManager = callManager;
|
|
106
113
|
this.user = null;
|
|
107
114
|
this.userDetails = userDetails;
|
|
108
115
|
this.device = null;
|
|
@@ -280,6 +287,25 @@ export class ClientManager extends EventEmitter {
|
|
|
280
287
|
|
|
281
288
|
private async parseResourceMsg(msg: ResourceMsg, header: Uint8Array) {
|
|
282
289
|
switch (msg.resourceType) {
|
|
290
|
+
case "call":
|
|
291
|
+
try {
|
|
292
|
+
const event = await this.callManager.handleResource({
|
|
293
|
+
action: msg.action,
|
|
294
|
+
actor: {
|
|
295
|
+
device: this.getDevice(),
|
|
296
|
+
user: this.getUser(),
|
|
297
|
+
},
|
|
298
|
+
data: msg.data,
|
|
299
|
+
transmissionID: msg.transmissionID,
|
|
300
|
+
});
|
|
301
|
+
this.sendSuccess(msg.transmissionID, event);
|
|
302
|
+
} catch (err: unknown) {
|
|
303
|
+
this.sendErr(
|
|
304
|
+
msg.transmissionID,
|
|
305
|
+
err instanceof Error ? err.message : String(err),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
283
309
|
case "mail":
|
|
284
310
|
if (msg.action === "CREATE") {
|
|
285
311
|
const mailResult = MailWSSchema.safeParse(msg.data);
|
|
@@ -308,15 +334,28 @@ export class ClientManager extends EventEmitter {
|
|
|
308
334
|
);
|
|
309
335
|
|
|
310
336
|
this.sendSuccess(msg.transmissionID, null);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
const callWake = callWakeDispatchData(mail);
|
|
338
|
+
if (callWake) {
|
|
339
|
+
this.notify(
|
|
340
|
+
recipientDevice.owner,
|
|
341
|
+
"callWake",
|
|
342
|
+
msg.transmissionID,
|
|
343
|
+
callWake,
|
|
344
|
+
mail.recipient,
|
|
345
|
+
undefined,
|
|
346
|
+
mail.nonce,
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
this.notify(
|
|
350
|
+
recipientDevice.owner,
|
|
351
|
+
"mail",
|
|
352
|
+
msg.transmissionID,
|
|
353
|
+
null,
|
|
354
|
+
mail.recipient,
|
|
355
|
+
mail.authorID,
|
|
356
|
+
mail.nonce,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
320
359
|
} catch (err: unknown) {
|
|
321
360
|
this.sendErr(msg.transmissionID, String(err));
|
|
322
361
|
}
|
package/src/Database.ts
CHANGED
|
@@ -130,8 +130,10 @@ export interface InternalUserRecord extends UserRecord {
|
|
|
130
130
|
hashAlgo: string;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export type NotificationChannel = "apnsVoip" | "expo" | "fcmCall";
|
|
134
|
+
|
|
133
135
|
export interface NotificationSubscription {
|
|
134
|
-
channel:
|
|
136
|
+
channel: NotificationChannel;
|
|
135
137
|
createdAt: string;
|
|
136
138
|
deviceID: string;
|
|
137
139
|
enabled: boolean;
|
|
@@ -144,7 +146,7 @@ export interface NotificationSubscription {
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
export interface SaveNotificationSubscriptionInput {
|
|
147
|
-
channel:
|
|
149
|
+
channel: NotificationChannel;
|
|
148
150
|
deviceID: string;
|
|
149
151
|
events: string[];
|
|
150
152
|
platform?: null | string;
|
|
@@ -1627,6 +1629,13 @@ function normalizeRegistrationUsername(
|
|
|
1627
1629
|
return `key_${seed}`;
|
|
1628
1630
|
}
|
|
1629
1631
|
|
|
1632
|
+
function parseNotificationChannel(channel: string): NotificationChannel {
|
|
1633
|
+
if (channel === "apnsVoip" || channel === "expo" || channel === "fcmCall") {
|
|
1634
|
+
return channel;
|
|
1635
|
+
}
|
|
1636
|
+
return "expo";
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1630
1639
|
function toDevice(row: {
|
|
1631
1640
|
deleted: number;
|
|
1632
1641
|
deviceID: string;
|
|
@@ -1676,7 +1685,7 @@ function toNotificationSubscription(row: {
|
|
|
1676
1685
|
}): NotificationSubscription {
|
|
1677
1686
|
return {
|
|
1678
1687
|
...row,
|
|
1679
|
-
channel:
|
|
1688
|
+
channel: parseNotificationChannel(row.channel),
|
|
1680
1689
|
enabled: Boolean(row.enabled),
|
|
1681
1690
|
events: decodeNotificationEvents(row.events),
|
|
1682
1691
|
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IceServerConfig } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { IceServerConfigSchema } from "@vex-chat/types";
|
|
10
|
+
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
|
|
13
|
+
const CLOUDFLARE_TURN_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys";
|
|
14
|
+
const DEFAULT_CLOUDFLARE_TURN_TIMEOUT_MS = 5_000;
|
|
15
|
+
const DEFAULT_CLOUDFLARE_TURN_TTL_SECONDS = 86_400;
|
|
16
|
+
|
|
17
|
+
const cloudflareIceServersResponseSchema = z.object({
|
|
18
|
+
iceServers: z.array(IceServerConfigSchema),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export async function resolveIceServersFromEnv(): Promise<IceServerConfig[]> {
|
|
22
|
+
const jsonOverride = readJsonIceServersFromEnv();
|
|
23
|
+
if (jsonOverride) {
|
|
24
|
+
return jsonOverride;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const staticServers = readStaticIceServersFromEnv();
|
|
28
|
+
const cloudflareServers = await readCloudflareIceServersFromEnv();
|
|
29
|
+
return [...staticServers, ...cloudflareServers];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readCloudflareIceServersFromEnv(): Promise<IceServerConfig[]> {
|
|
33
|
+
const keyID = readEnv([
|
|
34
|
+
"SPIRE_CLOUDFLARE_TURN_KEY_ID",
|
|
35
|
+
"CLOUDFLARE_TURN_KEY_ID",
|
|
36
|
+
"TURN_KEY_ID",
|
|
37
|
+
]);
|
|
38
|
+
const apiToken = readEnv([
|
|
39
|
+
"SPIRE_CLOUDFLARE_TURN_API_TOKEN",
|
|
40
|
+
"CLOUDFLARE_TURN_API_TOKEN",
|
|
41
|
+
"TURN_KEY_API_TOKEN",
|
|
42
|
+
]);
|
|
43
|
+
const endpointOverride = readEnv(["SPIRE_CLOUDFLARE_TURN_ENDPOINT"]);
|
|
44
|
+
if (!keyID && !apiToken && !endpointOverride) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!apiToken || (!keyID && !endpointOverride)) {
|
|
49
|
+
console.warn(
|
|
50
|
+
"[spire-calls] Cloudflare TURN is partially configured; set SPIRE_CLOUDFLARE_TURN_KEY_ID and SPIRE_CLOUDFLARE_TURN_API_TOKEN",
|
|
51
|
+
);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let endpoint = endpointOverride;
|
|
56
|
+
if (!endpoint) {
|
|
57
|
+
if (!keyID) {
|
|
58
|
+
console.warn(
|
|
59
|
+
"[spire-calls] Cloudflare TURN is partially configured; set SPIRE_CLOUDFLARE_TURN_KEY_ID and SPIRE_CLOUDFLARE_TURN_API_TOKEN",
|
|
60
|
+
);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
endpoint = `${CLOUDFLARE_TURN_ENDPOINT}/${encodeURIComponent(keyID)}/credentials/generate-ice-servers`;
|
|
64
|
+
}
|
|
65
|
+
const ttl = readPositiveIntegerEnv(
|
|
66
|
+
"SPIRE_CLOUDFLARE_TURN_TTL_SECONDS",
|
|
67
|
+
DEFAULT_CLOUDFLARE_TURN_TTL_SECONDS,
|
|
68
|
+
);
|
|
69
|
+
const timeoutMs = readPositiveIntegerEnv(
|
|
70
|
+
"SPIRE_CLOUDFLARE_TURN_TIMEOUT_MS",
|
|
71
|
+
DEFAULT_CLOUDFLARE_TURN_TIMEOUT_MS,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
controller.abort();
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(endpoint, {
|
|
81
|
+
body: JSON.stringify({ ttl }),
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${apiToken}`,
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
method: "POST",
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Cloudflare TURN credential request failed with ${response.status}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payload = await response.json();
|
|
97
|
+
return cloudflareIceServersResponseSchema.parse(payload).iceServers;
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
console.warn(
|
|
100
|
+
"[spire-calls] failed to fetch Cloudflare TURN credentials",
|
|
101
|
+
err instanceof Error ? err.message : String(err),
|
|
102
|
+
);
|
|
103
|
+
return [];
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readEnv(names: string[]): string | undefined {
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
const value = process.env[name]?.trim();
|
|
112
|
+
if (value) {
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readJsonIceServersFromEnv(): IceServerConfig[] | null {
|
|
120
|
+
const json = process.env["SPIRE_ICE_SERVERS"]?.trim();
|
|
121
|
+
if (!json) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(json) as unknown;
|
|
127
|
+
return z.array(IceServerConfigSchema).parse(parsed);
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
console.warn(
|
|
130
|
+
"[spire-calls] ignoring invalid SPIRE_ICE_SERVERS",
|
|
131
|
+
err instanceof Error ? err.message : String(err),
|
|
132
|
+
);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
|
138
|
+
const raw = process.env[name]?.trim();
|
|
139
|
+
if (!raw) {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parsed = Number.parseInt(raw, 10);
|
|
144
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.warn(
|
|
149
|
+
`[spire-calls] ignoring invalid ${name}; expected a positive integer`,
|
|
150
|
+
);
|
|
151
|
+
return fallback;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readStaticIceServersFromEnv(): IceServerConfig[] {
|
|
155
|
+
const servers: IceServerConfig[] = [];
|
|
156
|
+
for (const url of splitCsvEnv("SPIRE_STUN_URLS")) {
|
|
157
|
+
servers.push({ urls: url });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const turnUrls = splitCsvEnv("SPIRE_TURN_URLS");
|
|
161
|
+
if (turnUrls.length > 0) {
|
|
162
|
+
const username = process.env["SPIRE_TURN_USERNAME"]?.trim();
|
|
163
|
+
const credential = process.env["SPIRE_TURN_CREDENTIAL"]?.trim();
|
|
164
|
+
const [firstTurnUrl] = turnUrls;
|
|
165
|
+
if (!firstTurnUrl) {
|
|
166
|
+
return servers;
|
|
167
|
+
}
|
|
168
|
+
const urls: string | string[] =
|
|
169
|
+
turnUrls.length === 1 ? firstTurnUrl : turnUrls;
|
|
170
|
+
servers.push({
|
|
171
|
+
...(credential ? { credential } : {}),
|
|
172
|
+
urls,
|
|
173
|
+
...(username ? { username } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return servers;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function splitCsvEnv(name: string): string[] {
|
|
181
|
+
return (process.env[name] ?? "")
|
|
182
|
+
.split(",")
|
|
183
|
+
.map((value) => value.trim())
|
|
184
|
+
.filter((value) => value.length > 0);
|
|
185
|
+
}
|