@camstack/addon-cloudflare-turn 0.1.15 → 0.1.17
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/cloudflare-turn.addon.js +251 -21
- package/dist/cloudflare-turn.addon.js.map +1 -1
- package/dist/cloudflare-turn.addon.mjs +253 -23
- package/dist/cloudflare-turn.addon.mjs.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -2
- package/package.json +1 -1
|
@@ -1,30 +1,232 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const types = require("@camstack/types");
|
|
4
|
+
const zod = require("zod");
|
|
5
|
+
function parseCloudflareTurnResponse(body) {
|
|
6
|
+
if (!body || typeof body !== "object") return [];
|
|
7
|
+
const b = body;
|
|
8
|
+
if ("urls" in b) {
|
|
9
|
+
const s = coerceTurnServer(b);
|
|
10
|
+
return s ? [s] : [];
|
|
11
|
+
}
|
|
12
|
+
const ice = b["iceServers"];
|
|
13
|
+
if (!ice) return [];
|
|
14
|
+
if (Array.isArray(ice)) {
|
|
15
|
+
return ice.map((entry) => entry && typeof entry === "object" ? coerceTurnServer(entry) : null).filter((s) => s !== null);
|
|
16
|
+
}
|
|
17
|
+
if (typeof ice === "object") {
|
|
18
|
+
const s = coerceTurnServer(ice);
|
|
19
|
+
return s ? [s] : [];
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
function coerceTurnServer(obj) {
|
|
24
|
+
const urls = obj["urls"];
|
|
25
|
+
if (!urls) return null;
|
|
26
|
+
let normalisedUrls;
|
|
27
|
+
if (Array.isArray(urls)) {
|
|
28
|
+
const strs = urls.filter((u) => typeof u === "string");
|
|
29
|
+
if (strs.length === 0) return null;
|
|
30
|
+
normalisedUrls = strs;
|
|
31
|
+
} else if (typeof urls === "string") {
|
|
32
|
+
normalisedUrls = urls;
|
|
33
|
+
} else {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const username = obj["username"];
|
|
37
|
+
const credential = obj["credential"];
|
|
38
|
+
return {
|
|
39
|
+
urls: normalisedUrls,
|
|
40
|
+
...typeof username === "string" ? { username } : {},
|
|
41
|
+
...typeof credential === "string" ? { credential } : {}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const TURN_API_BASE = "https://rtc.live.cloudflare.com/v1/turn/keys";
|
|
45
|
+
const TTL_SECONDS = 86400;
|
|
46
|
+
const REFRESH_BEFORE_MS = TTL_SECONDS * 1e3 / 2;
|
|
4
47
|
class CloudflareTurnService {
|
|
5
|
-
constructor(
|
|
48
|
+
constructor(config, logger) {
|
|
49
|
+
this.config = config;
|
|
6
50
|
this.logger = logger;
|
|
7
51
|
}
|
|
8
52
|
id = "cloudflare-turn";
|
|
9
53
|
name = "Cloudflare TURN";
|
|
54
|
+
cached = null;
|
|
55
|
+
/** In-flight refresh promise — coalesces concurrent fetches. */
|
|
56
|
+
inflight = null;
|
|
10
57
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
58
|
+
* One-shot probe used by the settings UI's "Test credentials" button.
|
|
59
|
+
* Bypasses the cache so the operator sees what the LIVE Cloudflare
|
|
60
|
+
* response is for the currently-typed credentials. Returns a
|
|
61
|
+
* structured success/failure envelope so the form can render a clear
|
|
62
|
+
* outcome (✓ with server count, ✗ with status + body).
|
|
13
63
|
*/
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
64
|
+
async testCredentials() {
|
|
65
|
+
if (!this.config.accountId || !this.config.apiToken) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
body: "TURN Key ID and API Token must both be filled before testing."
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`;
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetch(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${this.config.apiToken}`,
|
|
78
|
+
"Content-Type": "application/json"
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({ ttl: TTL_SECONDS })
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
body: `Network error: ${err instanceof Error ? err.message : String(err)}`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const text = await res.text().catch(() => "");
|
|
90
|
+
const looksLikeKeyMismatch = res.status === 404 && text.includes("cannot find specified key");
|
|
91
|
+
const hint = looksLikeKeyMismatch ? 'The "TURN Key ID" does not match any of your TURN apps. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown on that page (not the Cloudflare Account ID).' : res.status === 401 || res.status === 403 ? "Authentication failed — the API Token is invalid or doesn't belong to this TURN app. Regenerate the API Token from the TURN app's page." : void 0;
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
status: res.status,
|
|
95
|
+
body: text.slice(0, 500),
|
|
96
|
+
...hint ? { hint } : {}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
let body;
|
|
100
|
+
try {
|
|
101
|
+
body = await res.json();
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, body: "Response was not JSON" };
|
|
104
|
+
}
|
|
105
|
+
const servers = parseCloudflareTurnResponse(body);
|
|
106
|
+
if (servers.length === 0) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
body: `Could not parse ICE servers from response. Raw: ${JSON.stringify(body).slice(0, 400)}`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0);
|
|
113
|
+
const hasCredentials = servers.some((s) => s.username && s.credential);
|
|
114
|
+
const firstServer = servers[0];
|
|
115
|
+
const firstUrl = firstServer ? Array.isArray(firstServer.urls) ? firstServer.urls[0] : firstServer.urls : void 0;
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
urlCount: totalUrls,
|
|
119
|
+
hasCredentials,
|
|
120
|
+
...firstUrl ? { firstUrl: String(firstUrl) } : {}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async getTurnServers() {
|
|
124
|
+
if (!this.config.accountId || !this.config.apiToken) {
|
|
125
|
+
this.logger.warn("Cloudflare TURN: credentials not configured — skipping fetch");
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
if (this.cached) {
|
|
129
|
+
const age = Date.now() - this.cached.fetchedAt;
|
|
130
|
+
if (age < REFRESH_BEFORE_MS) return this.cached.servers;
|
|
131
|
+
void this.refresh();
|
|
132
|
+
return this.cached.servers;
|
|
133
|
+
}
|
|
134
|
+
return this.refresh();
|
|
135
|
+
}
|
|
136
|
+
async refresh() {
|
|
137
|
+
if (this.inflight) return this.inflight;
|
|
138
|
+
this.inflight = this.doFetch().finally(() => {
|
|
139
|
+
this.inflight = null;
|
|
140
|
+
});
|
|
141
|
+
return this.inflight;
|
|
142
|
+
}
|
|
143
|
+
async doFetch() {
|
|
144
|
+
const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`;
|
|
145
|
+
this.logger.debug("Fetching TURN servers from Cloudflare", { meta: { url } });
|
|
146
|
+
let res;
|
|
147
|
+
try {
|
|
148
|
+
res = await fetch(url, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${this.config.apiToken}`,
|
|
152
|
+
"Content-Type": "application/json"
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({ ttl: TTL_SECONDS })
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
158
|
+
this.logger.error("Cloudflare TURN fetch failed (network error)", { meta: { error: msg } });
|
|
159
|
+
return this.cached?.servers ?? [];
|
|
160
|
+
}
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
const text = await res.text().catch(() => "");
|
|
163
|
+
const looksLikeKeyMismatch = res.status === 404 && text.includes("cannot find specified key");
|
|
164
|
+
const k = this.config.accountId;
|
|
165
|
+
const keyMasked = k.length > 8 ? `${k.slice(0, 4)}…${k.slice(-4)}` : "***";
|
|
166
|
+
this.logger.error("Cloudflare TURN fetch failed", {
|
|
167
|
+
meta: {
|
|
168
|
+
status: res.status,
|
|
169
|
+
statusText: res.statusText,
|
|
170
|
+
body: text.slice(0, 500),
|
|
171
|
+
url: `${TURN_API_BASE}/${keyMasked}/credentials/generate-ice-servers`,
|
|
172
|
+
keyMasked,
|
|
173
|
+
tokenLength: this.config.apiToken.length,
|
|
174
|
+
hint: looksLikeKeyMismatch ? 'The "TURN Key ID" field does NOT accept the Cloudflare Account ID. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown there (a separate hex id from your account).' : void 0
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return this.cached?.servers ?? [];
|
|
178
|
+
}
|
|
179
|
+
let body;
|
|
180
|
+
try {
|
|
181
|
+
body = await res.json();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
184
|
+
this.logger.error("Cloudflare TURN response not JSON", { meta: { error: msg } });
|
|
185
|
+
return this.cached?.servers ?? [];
|
|
186
|
+
}
|
|
187
|
+
const servers = parseCloudflareTurnResponse(body);
|
|
188
|
+
if (servers.length === 0) {
|
|
189
|
+
this.logger.error("Cloudflare TURN response had no usable ICE servers", {
|
|
190
|
+
meta: { body: JSON.stringify(body).slice(0, 800) }
|
|
191
|
+
});
|
|
192
|
+
return this.cached?.servers ?? [];
|
|
193
|
+
}
|
|
194
|
+
this.cached = { servers, fetchedAt: Date.now() };
|
|
195
|
+
const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0);
|
|
196
|
+
const hasCredentials = servers.some((s) => s.username && s.credential);
|
|
197
|
+
this.logger.info("Cloudflare TURN servers fetched", {
|
|
198
|
+
meta: {
|
|
199
|
+
serverCount: servers.length,
|
|
200
|
+
urlCount: totalUrls,
|
|
201
|
+
hasCredentials,
|
|
202
|
+
ttlSeconds: TTL_SECONDS
|
|
24
203
|
}
|
|
25
|
-
|
|
204
|
+
});
|
|
205
|
+
return servers;
|
|
26
206
|
}
|
|
27
207
|
}
|
|
208
|
+
const TestResultSchema = zod.z.discriminatedUnion("ok", [
|
|
209
|
+
zod.z.object({
|
|
210
|
+
ok: zod.z.literal(true),
|
|
211
|
+
urlCount: zod.z.number(),
|
|
212
|
+
hasCredentials: zod.z.boolean(),
|
|
213
|
+
firstUrl: zod.z.string().optional()
|
|
214
|
+
}),
|
|
215
|
+
zod.z.object({
|
|
216
|
+
ok: zod.z.literal(false),
|
|
217
|
+
status: zod.z.number().optional(),
|
|
218
|
+
body: zod.z.string(),
|
|
219
|
+
hint: zod.z.string().optional()
|
|
220
|
+
})
|
|
221
|
+
]);
|
|
222
|
+
const cloudflareTurnActions = types.defineCustomActions({
|
|
223
|
+
testCredentials: types.customAction(
|
|
224
|
+
// `.optional()` because tRPC elides `{}` inputs on the wire.
|
|
225
|
+
zod.z.object({}).optional(),
|
|
226
|
+
TestResultSchema,
|
|
227
|
+
{ kind: "mutation" }
|
|
228
|
+
)
|
|
229
|
+
});
|
|
28
230
|
class CloudflareTurnAddon extends types.BaseAddon {
|
|
29
231
|
service = null;
|
|
30
232
|
constructor() {
|
|
@@ -33,7 +235,16 @@ class CloudflareTurnAddon extends types.BaseAddon {
|
|
|
33
235
|
async onInitialize() {
|
|
34
236
|
this.service = new CloudflareTurnService(this.config, this.ctx.logger);
|
|
35
237
|
this.ctx.logger.info("Cloudflare TURN initialized");
|
|
36
|
-
return
|
|
238
|
+
return {
|
|
239
|
+
providers: [{ capability: types.turnProviderCapability, provider: this.service }],
|
|
240
|
+
customActions: cloudflareTurnActions,
|
|
241
|
+
actionHandlers: {
|
|
242
|
+
testCredentials: async () => {
|
|
243
|
+
const svc = new CloudflareTurnService(this.config, this.ctx.logger);
|
|
244
|
+
return svc.testCredentials();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
37
248
|
}
|
|
38
249
|
async onShutdown() {
|
|
39
250
|
this.service = null;
|
|
@@ -54,16 +265,16 @@ class CloudflareTurnAddon extends types.BaseAddon {
|
|
|
54
265
|
{
|
|
55
266
|
type: "info",
|
|
56
267
|
key: "turnHelp",
|
|
57
|
-
label: "How to get the
|
|
268
|
+
label: "How to get the TURN Key ID + API Token",
|
|
58
269
|
format: "html",
|
|
59
|
-
content:
|
|
270
|
+
content: `<ul><li>Open <a href="https://dash.cloudflare.com/?to=/:account/calls" target="_blank" rel="noopener noreferrer">Cloudflare Dashboard → Calls</a> (the product's URL path is still <code>/calls</code> after the Realtime rebrand).</li><li>Pick the <strong>TURN</strong> section and click <strong>Create TURN App</strong> (or pick an existing one).</li><li>Cloudflare generates a pair: <strong>Token ID</strong> (hex string, also shown as "Key ID" in some docs) and an <strong>API Token</strong>. Both are app-scoped — they are <em>NOT</em> the same as your Cloudflare Account ID.</li><li>Paste them in the fields below.</li><li>The integration calls <code>rtc.live.cloudflare.com/v1/turn/keys/<TokenId>/credentials/generate-ice-servers</code> to mint short-lived ICE credentials per WebRTC session.</li></ul>`,
|
|
60
271
|
variant: "info"
|
|
61
272
|
},
|
|
62
273
|
this.field({
|
|
63
274
|
type: "text",
|
|
64
275
|
key: "accountId",
|
|
65
|
-
label: "
|
|
66
|
-
description: "
|
|
276
|
+
label: "TURN Key ID",
|
|
277
|
+
description: "Token ID of the Realtime TURN app (NOT your Cloudflare Account ID — it's a separate hex id generated when you create the TURN app).",
|
|
67
278
|
placeholder: "a1b2c3d4e5f6...",
|
|
68
279
|
required: true
|
|
69
280
|
}),
|
|
@@ -71,10 +282,28 @@ class CloudflareTurnAddon extends types.BaseAddon {
|
|
|
71
282
|
type: "password",
|
|
72
283
|
key: "apiToken",
|
|
73
284
|
label: "API Token",
|
|
74
|
-
description: "
|
|
285
|
+
description: "The API Token shown alongside the Token ID when you created the TURN app. App-scoped — separate from any Cloudflare global API token.",
|
|
75
286
|
showToggle: true,
|
|
76
287
|
required: true
|
|
77
|
-
})
|
|
288
|
+
}),
|
|
289
|
+
{
|
|
290
|
+
type: "addon-action-button",
|
|
291
|
+
key: "testCredentials",
|
|
292
|
+
label: "Test credentials",
|
|
293
|
+
description: "Hit the Cloudflare TURN API with the current Key ID + API Token. On success shows the URL count + whether credentials came back; on failure shows the raw response with an operator hint.",
|
|
294
|
+
addonId: "cloudflare-turn",
|
|
295
|
+
action: "testCredentials",
|
|
296
|
+
buttonLabel: "Test",
|
|
297
|
+
successMessage: "✓ Fetched {urlCount} URL(s) — credentials: {hasCredentials}"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
type: "info",
|
|
301
|
+
key: "usageHelp",
|
|
302
|
+
label: "Usage & free quota",
|
|
303
|
+
format: "html",
|
|
304
|
+
content: `<p>Cloudflare's TURN credentials endpoint we use (<code>Calls:Read</code> scope) does not return usage / remaining-free-credit. Check current consumption + free quota in the dashboard:</p><ul><li><a href="https://dash.cloudflare.com/?to=/:account/calls" target="_blank" rel="noopener noreferrer">Cloudflare Dashboard → Calls</a> — overview of TURN apps with usage graphs and current-month consumption (the URL still says <code>/calls</code> after the "Realtime" rebrand).</li></ul><p class="text-foreground-subtle text-xs">Wiring a live counter requires a separate API token with <code>Account → Analytics → Read</code> scope and Cloudflare's GraphQL Analytics endpoint. Filed as a follow-up; use the dashboard link above in the meantime.</p>`,
|
|
305
|
+
variant: "info"
|
|
306
|
+
}
|
|
78
307
|
]
|
|
79
308
|
}
|
|
80
309
|
]
|
|
@@ -83,4 +312,5 @@ class CloudflareTurnAddon extends types.BaseAddon {
|
|
|
83
312
|
}
|
|
84
313
|
exports.CloudflareTurnAddon = CloudflareTurnAddon;
|
|
85
314
|
exports.CloudflareTurnService = CloudflareTurnService;
|
|
315
|
+
exports.customActions = cloudflareTurnActions;
|
|
86
316
|
//# sourceMappingURL=cloudflare-turn.addon.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cloudflare-turn.addon.js","sources":["../src/cloudflare-turn.ts","../src/cloudflare-turn.addon.ts"],"sourcesContent":["import type { IScopedLogger } from '@camstack/types'\n\nexport interface CloudflareTurnConfig {\n readonly apiToken: string\n readonly accountId: string\n}\n\ntype TurnServer = { urls: string | string[]; username?: string; credential?: string }\n\n/**\n * Cloudflare TURN/STUN provider.\n *\n * Implements the `turn-provider` capability (system collection). Called\n * by the `webrtc` capability implementations when building ICE server\n * lists for a new peer connection. Each call SHOULD fetch fresh\n * short-lived credentials — currently stubbed with static values until\n * the Cloudflare Calls API integration is wired.\n */\nexport class CloudflareTurnService {\n readonly id = 'cloudflare-turn'\n readonly name = 'Cloudflare TURN'\n\n constructor(\n _config: CloudflareTurnConfig,\n private readonly logger: IScopedLogger,\n ) {}\n\n /**\n * Return the current TURN/STUN server list with credentials.\n * Implements `turn-provider` capability.\n */\n getTurnServers(): readonly TurnServer[] {\n this.logger.debug('Fetching TURN servers from Cloudflare')\n // TODO: implement real Cloudflare TURN integration.\n // Real integration requires a Cloudflare Calls API call using this.apiToken/accountId.\n return [\n {\n urls: [\n 'turn:turn.cloudflare.com:3478?transport=udp',\n 'turn:turn.cloudflare.com:3478?transport=tcp',\n ],\n username: 'temp-user',\n credential: 'temp-credential',\n },\n ]\n }\n}\n","import type { ProviderRegistration } from '@camstack/types'\nimport { BaseAddon, turnProviderCapability } from '@camstack/types'\nimport { CloudflareTurnService } from './cloudflare-turn'\nimport type { CloudflareTurnConfig } from './cloudflare-turn'\n\n/**\n * Settings redesign Phase 3: cloudflare-turn is node-level credentials\n * storage. Implements `getGlobalSettings`.\n */\nexport class CloudflareTurnAddon extends BaseAddon<CloudflareTurnConfig> {\n private service: CloudflareTurnService | null = null\n\n constructor() {\n super({ apiToken: '', accountId: '' })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n this.service = new CloudflareTurnService(this.config, this.ctx.logger)\n this.ctx.logger.info('Cloudflare TURN initialized')\n return [{ capability: turnProviderCapability, provider: this.service }]\n }\n\n protected async onShutdown(): Promise<void> {\n this.service = null\n }\n\n getService(): CloudflareTurnService {\n if (!this.service) throw new Error('Cloudflare TURN not initialized')\n return this.service\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [\n {\n id: 'credentials',\n title: 'Cloudflare Credentials',\n description: 'API credentials for fetching TURN relay tokens from Cloudflare Realtime.',\n immediate: true,\n fields: [\n {\n type: 'info' as const,\n key: 'turnHelp',\n label: 'How to get the Account ID + API Token',\n format: 'html' as const,\n content:\n '<ul>' +\n '<li><strong>Account ID</strong> — open ' +\n '<a href=\"https://dash.cloudflare.com/\">dash.cloudflare.com</a>, ' +\n 'pick your account; the URL shows ' +\n '<code>dash.cloudflare.com/<accountId>/...</code>. ' +\n 'Copy that hex id.</li>' +\n '<li><strong>TURN Token</strong> — go to ' +\n '<a href=\"https://dash.cloudflare.com/?to=/:account/calls\">' +\n 'Realtime → TURN</a>, click <strong>Create TURN App</strong>, ' +\n 'copy the generated <em>Token ID</em> and <em>API Token</em>.</li>' +\n '<li>Paste the <em>API Token</em> below (the Token ID is the ' +\n 'Account ID variant for TURN apps — Cloudflare uses both naming ' +\n 'conventions across their dashboard).</li>' +\n '<li>Permissions auto-set when you create a Realtime app — ' +\n 'no manual scope picker needed.</li>' +\n '</ul>',\n variant: 'info' as const,\n },\n this.field({\n type: 'text',\n key: 'accountId',\n label: 'Account ID',\n description: 'Your Cloudflare account ID (the hex slug in the dashboard URL).',\n placeholder: 'a1b2c3d4e5f6...',\n required: true,\n }),\n this.field({\n type: 'password',\n key: 'apiToken',\n label: 'API Token',\n description: 'Cloudflare Realtime TURN app token (Calls: Read).',\n showToggle: true,\n required: true,\n }),\n ],\n },\n ],\n })\n }\n}\n"],"names":["BaseAddon","turnProviderCapability"],"mappings":";;;AAkBO,MAAM,sBAAsB;AAAA,EAIjC,YACE,SACiB,QACjB;AADiB,SAAA,SAAA;AAAA,EAChB;AAAA,EANM,KAAK;AAAA,EACL,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA,EAWhB,iBAAwC;AACtC,SAAK,OAAO,MAAM,uCAAuC;AAGzD,WAAO;AAAA,MACL;AAAA,QACE,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,QAAA;AAAA,QAEF,UAAU;AAAA,QACV,YAAY;AAAA,MAAA;AAAA,IACd;AAAA,EAEJ;AACF;ACrCO,MAAM,4BAA4BA,MAAAA,UAAgC;AAAA,EAC/D,UAAwC;AAAA,EAEhD,cAAc;AACZ,UAAM,EAAE,UAAU,IAAI,WAAW,IAAI;AAAA,EACvC;AAAA,EAEA,MAAgB,eAAgD;AAC9D,SAAK,UAAU,IAAI,sBAAsB,KAAK,QAAQ,KAAK,IAAI,MAAM;AACrE,SAAK,IAAI,OAAO,KAAK,6BAA6B;AAClD,WAAO,CAAC,EAAE,YAAYC,MAAAA,wBAAwB,UAAU,KAAK,SAAS;AAAA,EACxE;AAAA,EAEA,MAAgB,aAA4B;AAC1C,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,aAAoC;AAClC,QAAI,CAAC,KAAK,QAAS,OAAM,IAAI,MAAM,iCAAiC;AACpE,WAAO,KAAK;AAAA,EACd;AAAA,EAEU,uBAAuB;AAC/B,WAAO,KAAK,OAAO;AAAA,MACjB,UAAU;AAAA,QACR;AAAA,UACE,IAAI;AAAA,UACJ,OAAO;AAAA,UACP,aAAa;AAAA,UACb,WAAW;AAAA,UACX,QAAQ;AAAA,YACN;AAAA,cACE,MAAM;AAAA,cACN,KAAK;AAAA,cACL,OAAO;AAAA,cACP,QAAQ;AAAA,cACR,SACE;AAAA,cAgBF,SAAS;AAAA,YAAA;AAAA,YAEX,KAAK,MAAM;AAAA,cACT,MAAM;AAAA,cACN,KAAK;AAAA,cACL,OAAO;AAAA,cACP,aAAa;AAAA,cACb,aAAa;AAAA,cACb,UAAU;AAAA,YAAA,CACX;AAAA,YACD,KAAK,MAAM;AAAA,cACT,MAAM;AAAA,cACN,KAAK;AAAA,cACL,OAAO;AAAA,cACP,aAAa;AAAA,cACb,YAAY;AAAA,cACZ,UAAU;AAAA,YAAA,CACX;AAAA,UAAA;AAAA,QACH;AAAA,MACF;AAAA,IACF,CACD;AAAA,EACH;AACF;;;"}
|
|
1
|
+
{"version":3,"file":"cloudflare-turn.addon.js","sources":["../src/cloudflare-turn.ts","../src/cloudflare-turn-actions.ts","../src/cloudflare-turn.addon.ts"],"sourcesContent":["import type { IScopedLogger } from '@camstack/types'\n\nexport interface CloudflareTurnConfig {\n /**\n * Cloudflare Realtime TURN app's \"Token ID\" (also called \"Key ID\" in the\n * dashboard). Pasted into the `accountId` field for historical reasons —\n * Cloudflare's docs use both naming conventions.\n */\n readonly accountId: string\n /** Cloudflare Realtime TURN app's API Token (bearer credential). */\n readonly apiToken: string\n}\n\ntype TurnServer = { urls: string | string[]; username?: string; credential?: string }\n\nexport type TestCredentialsResult =\n | {\n ok: true\n urlCount: number\n hasCredentials: boolean\n firstUrl?: string\n }\n | {\n ok: false\n status?: number\n body: string\n hint?: string\n }\n\n/**\n * Cloudflare returns ICE servers in several flavours depending on which\n * endpoint variant + dashboard age:\n *\n * A) `{ iceServers: { urls: [...], username, credential } }` — single ICE server object\n * B) `{ iceServers: [ { urls, username, credential }, ... ] }` — array of objects\n * C) `{ urls: [...], username, credential }` — flat (no iceServers wrap)\n * D) `{ urls: 'stun:...', ... }` (single string instead of array)\n *\n * `parseCloudflareTurnResponse` normalises all of them into our\n * canonical `TurnServer[]`. Returns `[]` when the payload doesn't carry\n * any usable URL (caller logs + falls back to stale cache).\n */\nfunction parseCloudflareTurnResponse(body: unknown): readonly TurnServer[] {\n if (!body || typeof body !== 'object') return []\n const b = body as Record<string, unknown>\n // C+D: flat — body itself is an ICE server.\n if ('urls' in b) {\n const s = coerceTurnServer(b)\n return s ? [s] : []\n }\n const ice = b['iceServers']\n if (!ice) return []\n // B: array of servers.\n if (Array.isArray(ice)) {\n return ice\n .map((entry) => (entry && typeof entry === 'object' ? coerceTurnServer(entry as Record<string, unknown>) : null))\n .filter((s): s is TurnServer => s !== null)\n }\n // A: nested single object.\n if (typeof ice === 'object') {\n const s = coerceTurnServer(ice as Record<string, unknown>)\n return s ? [s] : []\n }\n return []\n}\n\nfunction coerceTurnServer(obj: Record<string, unknown>): TurnServer | null {\n const urls = obj['urls']\n if (!urls) return null\n let normalisedUrls: string | string[]\n if (Array.isArray(urls)) {\n const strs = urls.filter((u): u is string => typeof u === 'string')\n if (strs.length === 0) return null\n normalisedUrls = strs\n } else if (typeof urls === 'string') {\n normalisedUrls = urls\n } else {\n return null\n }\n const username = obj['username']\n const credential = obj['credential']\n return {\n urls: normalisedUrls,\n ...(typeof username === 'string' ? { username } : {}),\n ...(typeof credential === 'string' ? { credential } : {}),\n }\n}\n\nconst TURN_API_BASE = 'https://rtc.live.cloudflare.com/v1/turn/keys'\n\n/**\n * Credential lifetime (seconds) requested from Cloudflare. The hub\n * refreshes well before expiry; consumers (WebRTC sessions) get them\n * fresh per ICE-server enumeration.\n */\nconst TTL_SECONDS = 86_400\n/** Refresh slightly before TTL/2 so a slow request never returns expired creds. */\nconst REFRESH_BEFORE_MS = (TTL_SECONDS * 1000) / 2\n\ninterface CachedServers {\n readonly servers: readonly TurnServer[]\n readonly fetchedAt: number\n}\n\n/**\n * Cloudflare TURN/STUN provider.\n *\n * Implements the `turn-provider` capability. On each call, returns the\n * cached short-lived ICE servers (refreshed in the background ~12h\n * before expiry). The first call kicks off the initial fetch — until\n * it completes, an empty list is returned so the WebRTC layer falls\n * back to other providers / no TURN.\n */\nexport class CloudflareTurnService {\n readonly id = 'cloudflare-turn'\n readonly name = 'Cloudflare TURN'\n\n private cached: CachedServers | null = null\n /** In-flight refresh promise — coalesces concurrent fetches. */\n private inflight: Promise<readonly TurnServer[]> | null = null\n\n constructor(\n private readonly config: CloudflareTurnConfig,\n private readonly logger: IScopedLogger,\n ) {}\n\n /**\n * One-shot probe used by the settings UI's \"Test credentials\" button.\n * Bypasses the cache so the operator sees what the LIVE Cloudflare\n * response is for the currently-typed credentials. Returns a\n * structured success/failure envelope so the form can render a clear\n * outcome (✓ with server count, ✗ with status + body).\n */\n async testCredentials(): Promise<TestCredentialsResult> {\n if (!this.config.accountId || !this.config.apiToken) {\n return {\n ok: false,\n body: 'TURN Key ID and API Token must both be filled before testing.',\n }\n }\n const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`\n let res: Response\n try {\n res = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.config.apiToken}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ ttl: TTL_SECONDS }),\n })\n } catch (err) {\n return {\n ok: false,\n body: `Network error: ${err instanceof Error ? err.message : String(err)}`,\n }\n }\n if (!res.ok) {\n const text = await res.text().catch(() => '')\n const looksLikeKeyMismatch = res.status === 404 && text.includes('cannot find specified key')\n const hint = looksLikeKeyMismatch\n ? 'The \"TURN Key ID\" does not match any of your TURN apps. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown on that page (not the Cloudflare Account ID).'\n : res.status === 401 || res.status === 403\n ? 'Authentication failed — the API Token is invalid or doesn\\'t belong to this TURN app. Regenerate the API Token from the TURN app\\'s page.'\n : undefined\n return {\n ok: false,\n status: res.status,\n body: text.slice(0, 500),\n ...(hint ? { hint } : {}),\n }\n }\n let body: unknown\n try {\n body = await res.json()\n } catch {\n return { ok: false, body: 'Response was not JSON' }\n }\n const servers = parseCloudflareTurnResponse(body)\n if (servers.length === 0) {\n return {\n ok: false,\n body: `Could not parse ICE servers from response. Raw: ${JSON.stringify(body).slice(0, 400)}`,\n }\n }\n const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0)\n const hasCredentials = servers.some((s) => s.username && s.credential)\n const firstServer = servers[0]\n const firstUrl = firstServer\n ? (Array.isArray(firstServer.urls) ? firstServer.urls[0] : firstServer.urls)\n : undefined\n return {\n ok: true,\n urlCount: totalUrls,\n hasCredentials,\n ...(firstUrl ? { firstUrl: String(firstUrl) } : {}),\n }\n }\n\n async getTurnServers(): Promise<readonly TurnServer[]> {\n if (!this.config.accountId || !this.config.apiToken) {\n this.logger.warn('Cloudflare TURN: credentials not configured — skipping fetch')\n return []\n }\n // Cache hit: return immediately + refresh in the background when\n // we're past the halfway mark.\n if (this.cached) {\n const age = Date.now() - this.cached.fetchedAt\n if (age < REFRESH_BEFORE_MS) return this.cached.servers\n // Stale — kick off refresh but serve the (still-valid) cache to\n // the caller. The next caller after refresh completes gets fresh.\n void this.refresh()\n return this.cached.servers\n }\n // Cold: await the first fetch so the consumer doesn't see []\n // when credentials ARE configured.\n return this.refresh()\n }\n\n private async refresh(): Promise<readonly TurnServer[]> {\n if (this.inflight) return this.inflight\n this.inflight = this.doFetch().finally(() => { this.inflight = null })\n return this.inflight\n }\n\n private async doFetch(): Promise<readonly TurnServer[]> {\n const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`\n this.logger.debug('Fetching TURN servers from Cloudflare', { meta: { url } })\n let res: Response\n try {\n res = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.config.apiToken}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ ttl: TTL_SECONDS }),\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n this.logger.error('Cloudflare TURN fetch failed (network error)', { meta: { error: msg } })\n return this.cached?.servers ?? []\n }\n if (!res.ok) {\n const text = await res.text().catch(() => '')\n // 404 \"cannot find specified key\" is the operator-actionable hint:\n // the TURN Key ID field was filled with the wrong value (most often\n // the Cloudflare Account ID instead of the per-app Token ID).\n const looksLikeKeyMismatch = res.status === 404 && text.includes('cannot find specified key')\n // Mask all but the first/last 4 chars of the key so it's safe to\n // log + paste into a bug report.\n const k = this.config.accountId\n const keyMasked = k.length > 8 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '***'\n this.logger.error('Cloudflare TURN fetch failed', {\n meta: {\n status: res.status,\n statusText: res.statusText,\n body: text.slice(0, 500),\n url: `${TURN_API_BASE}/${keyMasked}/credentials/generate-ice-servers`,\n keyMasked,\n tokenLength: this.config.apiToken.length,\n hint: looksLikeKeyMismatch\n ? 'The \"TURN Key ID\" field does NOT accept the Cloudflare Account ID. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown there (a separate hex id from your account).'\n : undefined,\n },\n })\n return this.cached?.servers ?? []\n }\n let body: unknown\n try {\n body = await res.json()\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n this.logger.error('Cloudflare TURN response not JSON', { meta: { error: msg } })\n return this.cached?.servers ?? []\n }\n const servers = parseCloudflareTurnResponse(body)\n if (servers.length === 0) {\n this.logger.error('Cloudflare TURN response had no usable ICE servers', {\n meta: { body: JSON.stringify(body).slice(0, 800) },\n })\n return this.cached?.servers ?? []\n }\n this.cached = { servers, fetchedAt: Date.now() }\n const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0)\n const hasCredentials = servers.some((s) => s.username && s.credential)\n this.logger.info('Cloudflare TURN servers fetched', {\n meta: {\n serverCount: servers.length,\n urlCount: totalUrls,\n hasCredentials,\n ttlSeconds: TTL_SECONDS,\n },\n })\n return servers\n }\n}\n","/**\n * Cloudflare TURN — customActions catalog.\n *\n * testCredentials — fire one fetch against the configured TURN Key ID\n * + API Token and return either the success payload\n * (server count + first URL) or the error reason.\n * The settings form exposes a \"Test\" button below\n * the credential inputs so operators can validate\n * before going to the WebRTC consumer side.\n */\nimport { z } from 'zod'\nimport { customAction, defineCustomActions } from '@camstack/types'\n\nconst TestResultSchema = z.discriminatedUnion('ok', [\n z.object({\n ok: z.literal(true),\n urlCount: z.number(),\n hasCredentials: z.boolean(),\n firstUrl: z.string().optional(),\n }),\n z.object({\n ok: z.literal(false),\n status: z.number().optional(),\n body: z.string(),\n hint: z.string().optional(),\n }),\n])\n\nexport const cloudflareTurnActions = defineCustomActions({\n testCredentials: customAction(\n // `.optional()` because tRPC elides `{}` inputs on the wire.\n z.object({}).optional(),\n TestResultSchema,\n { kind: 'mutation' },\n ),\n})\n\nexport type CloudflareTurnActions = typeof cloudflareTurnActions\n","import type { AddonInitResult } from '@camstack/types'\nimport { BaseAddon, turnProviderCapability } from '@camstack/types'\nimport { CloudflareTurnService } from './cloudflare-turn'\nimport type { CloudflareTurnConfig } from './cloudflare-turn'\nimport { cloudflareTurnActions, type CloudflareTurnActions } from './cloudflare-turn-actions'\n\n/**\n * Static `customActions` re-export — picked up by the addon-service-factory\n * when this addon runs in a forked group-runner (the factory reads the\n * module's named export so the Moleculer actions get registered with the\n * canonical schemas). Without this the hub's custom-action dispatch\n * surfaces \"no custom action X\" because the catalog isn't visible.\n */\nexport { cloudflareTurnActions as customActions } from './cloudflare-turn-actions'\n\n/**\n * Settings redesign Phase 3: cloudflare-turn is node-level credentials\n * storage. Implements `getGlobalSettings`.\n */\nexport class CloudflareTurnAddon extends BaseAddon<CloudflareTurnConfig> {\n private service: CloudflareTurnService | null = null\n\n constructor() {\n super({ apiToken: '', accountId: '' })\n }\n\n protected async onInitialize(): Promise<AddonInitResult<CloudflareTurnActions>> {\n this.service = new CloudflareTurnService(this.config, this.ctx.logger)\n this.ctx.logger.info('Cloudflare TURN initialized')\n return {\n providers: [{ capability: turnProviderCapability, provider: this.service }],\n customActions: cloudflareTurnActions,\n actionHandlers: {\n testCredentials: async () => {\n // Bypass the cache so the test reflects the LIVE config (operator\n // just edited it). The service stays the canonical impl.\n const svc = new CloudflareTurnService(this.config, this.ctx.logger)\n return svc.testCredentials()\n },\n },\n }\n }\n\n protected async onShutdown(): Promise<void> {\n this.service = null\n }\n\n getService(): CloudflareTurnService {\n if (!this.service) throw new Error('Cloudflare TURN not initialized')\n return this.service\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [\n {\n id: 'credentials',\n title: 'Cloudflare Credentials',\n description: 'API credentials for fetching TURN relay tokens from Cloudflare Realtime.',\n immediate: true,\n fields: [\n {\n type: 'info' as const,\n key: 'turnHelp',\n label: 'How to get the TURN Key ID + API Token',\n format: 'html' as const,\n content:\n '<ul>' +\n '<li>Open ' +\n '<a href=\"https://dash.cloudflare.com/?to=/:account/calls\" ' +\n 'target=\"_blank\" rel=\"noopener noreferrer\">' +\n 'Cloudflare Dashboard → Calls</a> (the product\\'s URL path is still ' +\n '<code>/calls</code> after the Realtime rebrand).</li>' +\n '<li>Pick the <strong>TURN</strong> section and click ' +\n '<strong>Create TURN App</strong> (or pick an existing one).</li>' +\n '<li>Cloudflare generates a pair: <strong>Token ID</strong> ' +\n '(hex string, also shown as \"Key ID\" in some docs) and an ' +\n '<strong>API Token</strong>. Both are app-scoped — they are ' +\n '<em>NOT</em> the same as your Cloudflare Account ID.</li>' +\n '<li>Paste them in the fields below.</li>' +\n '<li>The integration calls ' +\n '<code>rtc.live.cloudflare.com/v1/turn/keys/<TokenId>/credentials/generate-ice-servers</code> ' +\n 'to mint short-lived ICE credentials per WebRTC session.</li>' +\n '</ul>',\n variant: 'info' as const,\n },\n this.field({\n type: 'text',\n key: 'accountId',\n label: 'TURN Key ID',\n description: 'Token ID of the Realtime TURN app (NOT your Cloudflare Account ID — it\\'s a separate hex id generated when you create the TURN app).',\n placeholder: 'a1b2c3d4e5f6...',\n required: true,\n }),\n this.field({\n type: 'password',\n key: 'apiToken',\n label: 'API Token',\n description: 'The API Token shown alongside the Token ID when you created the TURN app. App-scoped — separate from any Cloudflare global API token.',\n showToggle: true,\n required: true,\n }),\n {\n type: 'addon-action-button' as const,\n key: 'testCredentials',\n label: 'Test credentials',\n description: 'Hit the Cloudflare TURN API with the current Key ID + API Token. On success shows the URL count + whether credentials came back; on failure shows the raw response with an operator hint.',\n addonId: 'cloudflare-turn',\n action: 'testCredentials',\n buttonLabel: 'Test',\n successMessage: '✓ Fetched {urlCount} URL(s) — credentials: {hasCredentials}',\n },\n {\n type: 'info' as const,\n key: 'usageHelp',\n label: 'Usage & free quota',\n format: 'html' as const,\n content:\n '<p>Cloudflare\\'s TURN credentials endpoint we use (<code>Calls:Read</code> ' +\n 'scope) does not return usage / remaining-free-credit. Check current ' +\n 'consumption + free quota in the dashboard:</p>' +\n '<ul>' +\n '<li><a href=\"https://dash.cloudflare.com/?to=/:account/calls\" ' +\n 'target=\"_blank\" rel=\"noopener noreferrer\">' +\n 'Cloudflare Dashboard → Calls</a> — overview of TURN apps with usage ' +\n 'graphs and current-month consumption (the URL still says ' +\n '<code>/calls</code> after the \"Realtime\" rebrand).</li>' +\n '</ul>' +\n '<p class=\"text-foreground-subtle text-xs\">Wiring a live counter requires ' +\n 'a separate API token with <code>Account → Analytics → Read</code> scope ' +\n 'and Cloudflare\\'s GraphQL Analytics endpoint. Filed as a follow-up; ' +\n 'use the dashboard link above in the meantime.</p>',\n variant: 'info' as const,\n },\n ],\n },\n ],\n })\n }\n}\n"],"names":["z","defineCustomActions","customAction","BaseAddon","turnProviderCapability"],"mappings":"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"}
|
|
@@ -1,28 +1,230 @@
|
|
|
1
|
-
import { BaseAddon, turnProviderCapability } from "@camstack/types";
|
|
1
|
+
import { defineCustomActions, customAction, BaseAddon, turnProviderCapability } from "@camstack/types";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
function parseCloudflareTurnResponse(body) {
|
|
4
|
+
if (!body || typeof body !== "object") return [];
|
|
5
|
+
const b = body;
|
|
6
|
+
if ("urls" in b) {
|
|
7
|
+
const s = coerceTurnServer(b);
|
|
8
|
+
return s ? [s] : [];
|
|
9
|
+
}
|
|
10
|
+
const ice = b["iceServers"];
|
|
11
|
+
if (!ice) return [];
|
|
12
|
+
if (Array.isArray(ice)) {
|
|
13
|
+
return ice.map((entry) => entry && typeof entry === "object" ? coerceTurnServer(entry) : null).filter((s) => s !== null);
|
|
14
|
+
}
|
|
15
|
+
if (typeof ice === "object") {
|
|
16
|
+
const s = coerceTurnServer(ice);
|
|
17
|
+
return s ? [s] : [];
|
|
18
|
+
}
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
function coerceTurnServer(obj) {
|
|
22
|
+
const urls = obj["urls"];
|
|
23
|
+
if (!urls) return null;
|
|
24
|
+
let normalisedUrls;
|
|
25
|
+
if (Array.isArray(urls)) {
|
|
26
|
+
const strs = urls.filter((u) => typeof u === "string");
|
|
27
|
+
if (strs.length === 0) return null;
|
|
28
|
+
normalisedUrls = strs;
|
|
29
|
+
} else if (typeof urls === "string") {
|
|
30
|
+
normalisedUrls = urls;
|
|
31
|
+
} else {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const username = obj["username"];
|
|
35
|
+
const credential = obj["credential"];
|
|
36
|
+
return {
|
|
37
|
+
urls: normalisedUrls,
|
|
38
|
+
...typeof username === "string" ? { username } : {},
|
|
39
|
+
...typeof credential === "string" ? { credential } : {}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const TURN_API_BASE = "https://rtc.live.cloudflare.com/v1/turn/keys";
|
|
43
|
+
const TTL_SECONDS = 86400;
|
|
44
|
+
const REFRESH_BEFORE_MS = TTL_SECONDS * 1e3 / 2;
|
|
2
45
|
class CloudflareTurnService {
|
|
3
|
-
constructor(
|
|
46
|
+
constructor(config, logger) {
|
|
47
|
+
this.config = config;
|
|
4
48
|
this.logger = logger;
|
|
5
49
|
}
|
|
6
50
|
id = "cloudflare-turn";
|
|
7
51
|
name = "Cloudflare TURN";
|
|
52
|
+
cached = null;
|
|
53
|
+
/** In-flight refresh promise — coalesces concurrent fetches. */
|
|
54
|
+
inflight = null;
|
|
8
55
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
56
|
+
* One-shot probe used by the settings UI's "Test credentials" button.
|
|
57
|
+
* Bypasses the cache so the operator sees what the LIVE Cloudflare
|
|
58
|
+
* response is for the currently-typed credentials. Returns a
|
|
59
|
+
* structured success/failure envelope so the form can render a clear
|
|
60
|
+
* outcome (✓ with server count, ✗ with status + body).
|
|
11
61
|
*/
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
62
|
+
async testCredentials() {
|
|
63
|
+
if (!this.config.accountId || !this.config.apiToken) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
body: "TURN Key ID and API Token must both be filled before testing."
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`;
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = await fetch(url, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${this.config.apiToken}`,
|
|
76
|
+
"Content-Type": "application/json"
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({ ttl: TTL_SECONDS })
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
body: `Network error: ${err instanceof Error ? err.message : String(err)}`
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const text = await res.text().catch(() => "");
|
|
88
|
+
const looksLikeKeyMismatch = res.status === 404 && text.includes("cannot find specified key");
|
|
89
|
+
const hint = looksLikeKeyMismatch ? 'The "TURN Key ID" does not match any of your TURN apps. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown on that page (not the Cloudflare Account ID).' : res.status === 401 || res.status === 403 ? "Authentication failed — the API Token is invalid or doesn't belong to this TURN app. Regenerate the API Token from the TURN app's page." : void 0;
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
status: res.status,
|
|
93
|
+
body: text.slice(0, 500),
|
|
94
|
+
...hint ? { hint } : {}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
let body;
|
|
98
|
+
try {
|
|
99
|
+
body = await res.json();
|
|
100
|
+
} catch {
|
|
101
|
+
return { ok: false, body: "Response was not JSON" };
|
|
102
|
+
}
|
|
103
|
+
const servers = parseCloudflareTurnResponse(body);
|
|
104
|
+
if (servers.length === 0) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
body: `Could not parse ICE servers from response. Raw: ${JSON.stringify(body).slice(0, 400)}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0);
|
|
111
|
+
const hasCredentials = servers.some((s) => s.username && s.credential);
|
|
112
|
+
const firstServer = servers[0];
|
|
113
|
+
const firstUrl = firstServer ? Array.isArray(firstServer.urls) ? firstServer.urls[0] : firstServer.urls : void 0;
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
urlCount: totalUrls,
|
|
117
|
+
hasCredentials,
|
|
118
|
+
...firstUrl ? { firstUrl: String(firstUrl) } : {}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async getTurnServers() {
|
|
122
|
+
if (!this.config.accountId || !this.config.apiToken) {
|
|
123
|
+
this.logger.warn("Cloudflare TURN: credentials not configured — skipping fetch");
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
if (this.cached) {
|
|
127
|
+
const age = Date.now() - this.cached.fetchedAt;
|
|
128
|
+
if (age < REFRESH_BEFORE_MS) return this.cached.servers;
|
|
129
|
+
void this.refresh();
|
|
130
|
+
return this.cached.servers;
|
|
131
|
+
}
|
|
132
|
+
return this.refresh();
|
|
133
|
+
}
|
|
134
|
+
async refresh() {
|
|
135
|
+
if (this.inflight) return this.inflight;
|
|
136
|
+
this.inflight = this.doFetch().finally(() => {
|
|
137
|
+
this.inflight = null;
|
|
138
|
+
});
|
|
139
|
+
return this.inflight;
|
|
140
|
+
}
|
|
141
|
+
async doFetch() {
|
|
142
|
+
const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`;
|
|
143
|
+
this.logger.debug("Fetching TURN servers from Cloudflare", { meta: { url } });
|
|
144
|
+
let res;
|
|
145
|
+
try {
|
|
146
|
+
res = await fetch(url, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${this.config.apiToken}`,
|
|
150
|
+
"Content-Type": "application/json"
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ ttl: TTL_SECONDS })
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
156
|
+
this.logger.error("Cloudflare TURN fetch failed (network error)", { meta: { error: msg } });
|
|
157
|
+
return this.cached?.servers ?? [];
|
|
158
|
+
}
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const text = await res.text().catch(() => "");
|
|
161
|
+
const looksLikeKeyMismatch = res.status === 404 && text.includes("cannot find specified key");
|
|
162
|
+
const k = this.config.accountId;
|
|
163
|
+
const keyMasked = k.length > 8 ? `${k.slice(0, 4)}…${k.slice(-4)}` : "***";
|
|
164
|
+
this.logger.error("Cloudflare TURN fetch failed", {
|
|
165
|
+
meta: {
|
|
166
|
+
status: res.status,
|
|
167
|
+
statusText: res.statusText,
|
|
168
|
+
body: text.slice(0, 500),
|
|
169
|
+
url: `${TURN_API_BASE}/${keyMasked}/credentials/generate-ice-servers`,
|
|
170
|
+
keyMasked,
|
|
171
|
+
tokenLength: this.config.apiToken.length,
|
|
172
|
+
hint: looksLikeKeyMismatch ? 'The "TURN Key ID" field does NOT accept the Cloudflare Account ID. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown there (a separate hex id from your account).' : void 0
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return this.cached?.servers ?? [];
|
|
176
|
+
}
|
|
177
|
+
let body;
|
|
178
|
+
try {
|
|
179
|
+
body = await res.json();
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
182
|
+
this.logger.error("Cloudflare TURN response not JSON", { meta: { error: msg } });
|
|
183
|
+
return this.cached?.servers ?? [];
|
|
184
|
+
}
|
|
185
|
+
const servers = parseCloudflareTurnResponse(body);
|
|
186
|
+
if (servers.length === 0) {
|
|
187
|
+
this.logger.error("Cloudflare TURN response had no usable ICE servers", {
|
|
188
|
+
meta: { body: JSON.stringify(body).slice(0, 800) }
|
|
189
|
+
});
|
|
190
|
+
return this.cached?.servers ?? [];
|
|
191
|
+
}
|
|
192
|
+
this.cached = { servers, fetchedAt: Date.now() };
|
|
193
|
+
const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0);
|
|
194
|
+
const hasCredentials = servers.some((s) => s.username && s.credential);
|
|
195
|
+
this.logger.info("Cloudflare TURN servers fetched", {
|
|
196
|
+
meta: {
|
|
197
|
+
serverCount: servers.length,
|
|
198
|
+
urlCount: totalUrls,
|
|
199
|
+
hasCredentials,
|
|
200
|
+
ttlSeconds: TTL_SECONDS
|
|
22
201
|
}
|
|
23
|
-
|
|
202
|
+
});
|
|
203
|
+
return servers;
|
|
24
204
|
}
|
|
25
205
|
}
|
|
206
|
+
const TestResultSchema = z.discriminatedUnion("ok", [
|
|
207
|
+
z.object({
|
|
208
|
+
ok: z.literal(true),
|
|
209
|
+
urlCount: z.number(),
|
|
210
|
+
hasCredentials: z.boolean(),
|
|
211
|
+
firstUrl: z.string().optional()
|
|
212
|
+
}),
|
|
213
|
+
z.object({
|
|
214
|
+
ok: z.literal(false),
|
|
215
|
+
status: z.number().optional(),
|
|
216
|
+
body: z.string(),
|
|
217
|
+
hint: z.string().optional()
|
|
218
|
+
})
|
|
219
|
+
]);
|
|
220
|
+
const cloudflareTurnActions = defineCustomActions({
|
|
221
|
+
testCredentials: customAction(
|
|
222
|
+
// `.optional()` because tRPC elides `{}` inputs on the wire.
|
|
223
|
+
z.object({}).optional(),
|
|
224
|
+
TestResultSchema,
|
|
225
|
+
{ kind: "mutation" }
|
|
226
|
+
)
|
|
227
|
+
});
|
|
26
228
|
class CloudflareTurnAddon extends BaseAddon {
|
|
27
229
|
service = null;
|
|
28
230
|
constructor() {
|
|
@@ -31,7 +233,16 @@ class CloudflareTurnAddon extends BaseAddon {
|
|
|
31
233
|
async onInitialize() {
|
|
32
234
|
this.service = new CloudflareTurnService(this.config, this.ctx.logger);
|
|
33
235
|
this.ctx.logger.info("Cloudflare TURN initialized");
|
|
34
|
-
return
|
|
236
|
+
return {
|
|
237
|
+
providers: [{ capability: turnProviderCapability, provider: this.service }],
|
|
238
|
+
customActions: cloudflareTurnActions,
|
|
239
|
+
actionHandlers: {
|
|
240
|
+
testCredentials: async () => {
|
|
241
|
+
const svc = new CloudflareTurnService(this.config, this.ctx.logger);
|
|
242
|
+
return svc.testCredentials();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
35
246
|
}
|
|
36
247
|
async onShutdown() {
|
|
37
248
|
this.service = null;
|
|
@@ -52,16 +263,16 @@ class CloudflareTurnAddon extends BaseAddon {
|
|
|
52
263
|
{
|
|
53
264
|
type: "info",
|
|
54
265
|
key: "turnHelp",
|
|
55
|
-
label: "How to get the
|
|
266
|
+
label: "How to get the TURN Key ID + API Token",
|
|
56
267
|
format: "html",
|
|
57
|
-
content:
|
|
268
|
+
content: `<ul><li>Open <a href="https://dash.cloudflare.com/?to=/:account/calls" target="_blank" rel="noopener noreferrer">Cloudflare Dashboard → Calls</a> (the product's URL path is still <code>/calls</code> after the Realtime rebrand).</li><li>Pick the <strong>TURN</strong> section and click <strong>Create TURN App</strong> (or pick an existing one).</li><li>Cloudflare generates a pair: <strong>Token ID</strong> (hex string, also shown as "Key ID" in some docs) and an <strong>API Token</strong>. Both are app-scoped — they are <em>NOT</em> the same as your Cloudflare Account ID.</li><li>Paste them in the fields below.</li><li>The integration calls <code>rtc.live.cloudflare.com/v1/turn/keys/<TokenId>/credentials/generate-ice-servers</code> to mint short-lived ICE credentials per WebRTC session.</li></ul>`,
|
|
58
269
|
variant: "info"
|
|
59
270
|
},
|
|
60
271
|
this.field({
|
|
61
272
|
type: "text",
|
|
62
273
|
key: "accountId",
|
|
63
|
-
label: "
|
|
64
|
-
description: "
|
|
274
|
+
label: "TURN Key ID",
|
|
275
|
+
description: "Token ID of the Realtime TURN app (NOT your Cloudflare Account ID — it's a separate hex id generated when you create the TURN app).",
|
|
65
276
|
placeholder: "a1b2c3d4e5f6...",
|
|
66
277
|
required: true
|
|
67
278
|
}),
|
|
@@ -69,10 +280,28 @@ class CloudflareTurnAddon extends BaseAddon {
|
|
|
69
280
|
type: "password",
|
|
70
281
|
key: "apiToken",
|
|
71
282
|
label: "API Token",
|
|
72
|
-
description: "
|
|
283
|
+
description: "The API Token shown alongside the Token ID when you created the TURN app. App-scoped — separate from any Cloudflare global API token.",
|
|
73
284
|
showToggle: true,
|
|
74
285
|
required: true
|
|
75
|
-
})
|
|
286
|
+
}),
|
|
287
|
+
{
|
|
288
|
+
type: "addon-action-button",
|
|
289
|
+
key: "testCredentials",
|
|
290
|
+
label: "Test credentials",
|
|
291
|
+
description: "Hit the Cloudflare TURN API with the current Key ID + API Token. On success shows the URL count + whether credentials came back; on failure shows the raw response with an operator hint.",
|
|
292
|
+
addonId: "cloudflare-turn",
|
|
293
|
+
action: "testCredentials",
|
|
294
|
+
buttonLabel: "Test",
|
|
295
|
+
successMessage: "✓ Fetched {urlCount} URL(s) — credentials: {hasCredentials}"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
type: "info",
|
|
299
|
+
key: "usageHelp",
|
|
300
|
+
label: "Usage & free quota",
|
|
301
|
+
format: "html",
|
|
302
|
+
content: `<p>Cloudflare's TURN credentials endpoint we use (<code>Calls:Read</code> scope) does not return usage / remaining-free-credit. Check current consumption + free quota in the dashboard:</p><ul><li><a href="https://dash.cloudflare.com/?to=/:account/calls" target="_blank" rel="noopener noreferrer">Cloudflare Dashboard → Calls</a> — overview of TURN apps with usage graphs and current-month consumption (the URL still says <code>/calls</code> after the "Realtime" rebrand).</li></ul><p class="text-foreground-subtle text-xs">Wiring a live counter requires a separate API token with <code>Account → Analytics → Read</code> scope and Cloudflare's GraphQL Analytics endpoint. Filed as a follow-up; use the dashboard link above in the meantime.</p>`,
|
|
303
|
+
variant: "info"
|
|
304
|
+
}
|
|
76
305
|
]
|
|
77
306
|
}
|
|
78
307
|
]
|
|
@@ -81,6 +310,7 @@ class CloudflareTurnAddon extends BaseAddon {
|
|
|
81
310
|
}
|
|
82
311
|
export {
|
|
83
312
|
CloudflareTurnService as C,
|
|
84
|
-
CloudflareTurnAddon
|
|
313
|
+
CloudflareTurnAddon,
|
|
314
|
+
cloudflareTurnActions as customActions
|
|
85
315
|
};
|
|
86
316
|
//# sourceMappingURL=cloudflare-turn.addon.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cloudflare-turn.addon.mjs","sources":["../src/cloudflare-turn.ts","../src/cloudflare-turn.addon.ts"],"sourcesContent":["import type { IScopedLogger } from '@camstack/types'\n\nexport interface CloudflareTurnConfig {\n readonly apiToken: string\n readonly accountId: string\n}\n\ntype TurnServer = { urls: string | string[]; username?: string; credential?: string }\n\n/**\n * Cloudflare TURN/STUN provider.\n *\n * Implements the `turn-provider` capability (system collection). Called\n * by the `webrtc` capability implementations when building ICE server\n * lists for a new peer connection. Each call SHOULD fetch fresh\n * short-lived credentials — currently stubbed with static values until\n * the Cloudflare Calls API integration is wired.\n */\nexport class CloudflareTurnService {\n readonly id = 'cloudflare-turn'\n readonly name = 'Cloudflare TURN'\n\n constructor(\n _config: CloudflareTurnConfig,\n private readonly logger: IScopedLogger,\n ) {}\n\n /**\n * Return the current TURN/STUN server list with credentials.\n * Implements `turn-provider` capability.\n */\n getTurnServers(): readonly TurnServer[] {\n this.logger.debug('Fetching TURN servers from Cloudflare')\n // TODO: implement real Cloudflare TURN integration.\n // Real integration requires a Cloudflare Calls API call using this.apiToken/accountId.\n return [\n {\n urls: [\n 'turn:turn.cloudflare.com:3478?transport=udp',\n 'turn:turn.cloudflare.com:3478?transport=tcp',\n ],\n username: 'temp-user',\n credential: 'temp-credential',\n },\n ]\n }\n}\n","import type { ProviderRegistration } from '@camstack/types'\nimport { BaseAddon, turnProviderCapability } from '@camstack/types'\nimport { CloudflareTurnService } from './cloudflare-turn'\nimport type { CloudflareTurnConfig } from './cloudflare-turn'\n\n/**\n * Settings redesign Phase 3: cloudflare-turn is node-level credentials\n * storage. Implements `getGlobalSettings`.\n */\nexport class CloudflareTurnAddon extends BaseAddon<CloudflareTurnConfig> {\n private service: CloudflareTurnService | null = null\n\n constructor() {\n super({ apiToken: '', accountId: '' })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n this.service = new CloudflareTurnService(this.config, this.ctx.logger)\n this.ctx.logger.info('Cloudflare TURN initialized')\n return [{ capability: turnProviderCapability, provider: this.service }]\n }\n\n protected async onShutdown(): Promise<void> {\n this.service = null\n }\n\n getService(): CloudflareTurnService {\n if (!this.service) throw new Error('Cloudflare TURN not initialized')\n return this.service\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [\n {\n id: 'credentials',\n title: 'Cloudflare Credentials',\n description: 'API credentials for fetching TURN relay tokens from Cloudflare Realtime.',\n immediate: true,\n fields: [\n {\n type: 'info' as const,\n key: 'turnHelp',\n label: 'How to get the Account ID + API Token',\n format: 'html' as const,\n content:\n '<ul>' +\n '<li><strong>Account ID</strong> — open ' +\n '<a href=\"https://dash.cloudflare.com/\">dash.cloudflare.com</a>, ' +\n 'pick your account; the URL shows ' +\n '<code>dash.cloudflare.com/<accountId>/...</code>. ' +\n 'Copy that hex id.</li>' +\n '<li><strong>TURN Token</strong> — go to ' +\n '<a href=\"https://dash.cloudflare.com/?to=/:account/calls\">' +\n 'Realtime → TURN</a>, click <strong>Create TURN App</strong>, ' +\n 'copy the generated <em>Token ID</em> and <em>API Token</em>.</li>' +\n '<li>Paste the <em>API Token</em> below (the Token ID is the ' +\n 'Account ID variant for TURN apps — Cloudflare uses both naming ' +\n 'conventions across their dashboard).</li>' +\n '<li>Permissions auto-set when you create a Realtime app — ' +\n 'no manual scope picker needed.</li>' +\n '</ul>',\n variant: 'info' as const,\n },\n this.field({\n type: 'text',\n key: 'accountId',\n label: 'Account ID',\n description: 'Your Cloudflare account ID (the hex slug in the dashboard URL).',\n placeholder: 'a1b2c3d4e5f6...',\n required: true,\n }),\n this.field({\n type: 'password',\n key: 'apiToken',\n label: 'API Token',\n description: 'Cloudflare Realtime TURN app token (Calls: Read).',\n showToggle: true,\n required: true,\n }),\n ],\n },\n ],\n })\n }\n}\n"],"names":[],"mappings":";AAkBO,MAAM,sBAAsB;AAAA,EAIjC,YACE,SACiB,QACjB;AADiB,SAAA,SAAA;AAAA,EAChB;AAAA,EANM,KAAK;AAAA,EACL,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA,EAWhB,iBAAwC;AACtC,SAAK,OAAO,MAAM,uCAAuC;AAGzD,WAAO;AAAA,MACL;AAAA,QACE,MAAM;AAAA,UACJ;AAAA,UACA;AAAA,QAAA;AAAA,QAEF,UAAU;AAAA,QACV,YAAY;AAAA,MAAA;AAAA,IACd;AAAA,EAEJ;AACF;ACrCO,MAAM,4BAA4B,UAAgC;AAAA,EAC/D,UAAwC;AAAA,EAEhD,cAAc;AACZ,UAAM,EAAE,UAAU,IAAI,WAAW,IAAI;AAAA,EACvC;AAAA,EAEA,MAAgB,eAAgD;AAC9D,SAAK,UAAU,IAAI,sBAAsB,KAAK,QAAQ,KAAK,IAAI,MAAM;AACrE,SAAK,IAAI,OAAO,KAAK,6BAA6B;AAClD,WAAO,CAAC,EAAE,YAAY,wBAAwB,UAAU,KAAK,SAAS;AAAA,EACxE;AAAA,EAEA,MAAgB,aAA4B;AAC1C,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,aAAoC;AAClC,QAAI,CAAC,KAAK,QAAS,OAAM,IAAI,MAAM,iCAAiC;AACpE,WAAO,KAAK;AAAA,EACd;AAAA,EAEU,uBAAuB;AAC/B,WAAO,KAAK,OAAO;AAAA,MACjB,UAAU;AAAA,QACR;AAAA,UACE,IAAI;AAAA,UACJ,OAAO;AAAA,UACP,aAAa;AAAA,UACb,WAAW;AAAA,UACX,QAAQ;AAAA,YACN;AAAA,cACE,MAAM;AAAA,cACN,KAAK;AAAA,cACL,OAAO;AAAA,cACP,QAAQ;AAAA,cACR,SACE;AAAA,cAgBF,SAAS;AAAA,YAAA;AAAA,YAEX,KAAK,MAAM;AAAA,cACT,MAAM;AAAA,cACN,KAAK;AAAA,cACL,OAAO;AAAA,cACP,aAAa;AAAA,cACb,aAAa;AAAA,cACb,UAAU;AAAA,YAAA,CACX;AAAA,YACD,KAAK,MAAM;AAAA,cACT,MAAM;AAAA,cACN,KAAK;AAAA,cACL,OAAO;AAAA,cACP,aAAa;AAAA,cACb,YAAY;AAAA,cACZ,UAAU;AAAA,YAAA,CACX;AAAA,UAAA;AAAA,QACH;AAAA,MACF;AAAA,IACF,CACD;AAAA,EACH;AACF;"}
|
|
1
|
+
{"version":3,"file":"cloudflare-turn.addon.mjs","sources":["../src/cloudflare-turn.ts","../src/cloudflare-turn-actions.ts","../src/cloudflare-turn.addon.ts"],"sourcesContent":["import type { IScopedLogger } from '@camstack/types'\n\nexport interface CloudflareTurnConfig {\n /**\n * Cloudflare Realtime TURN app's \"Token ID\" (also called \"Key ID\" in the\n * dashboard). Pasted into the `accountId` field for historical reasons —\n * Cloudflare's docs use both naming conventions.\n */\n readonly accountId: string\n /** Cloudflare Realtime TURN app's API Token (bearer credential). */\n readonly apiToken: string\n}\n\ntype TurnServer = { urls: string | string[]; username?: string; credential?: string }\n\nexport type TestCredentialsResult =\n | {\n ok: true\n urlCount: number\n hasCredentials: boolean\n firstUrl?: string\n }\n | {\n ok: false\n status?: number\n body: string\n hint?: string\n }\n\n/**\n * Cloudflare returns ICE servers in several flavours depending on which\n * endpoint variant + dashboard age:\n *\n * A) `{ iceServers: { urls: [...], username, credential } }` — single ICE server object\n * B) `{ iceServers: [ { urls, username, credential }, ... ] }` — array of objects\n * C) `{ urls: [...], username, credential }` — flat (no iceServers wrap)\n * D) `{ urls: 'stun:...', ... }` (single string instead of array)\n *\n * `parseCloudflareTurnResponse` normalises all of them into our\n * canonical `TurnServer[]`. Returns `[]` when the payload doesn't carry\n * any usable URL (caller logs + falls back to stale cache).\n */\nfunction parseCloudflareTurnResponse(body: unknown): readonly TurnServer[] {\n if (!body || typeof body !== 'object') return []\n const b = body as Record<string, unknown>\n // C+D: flat — body itself is an ICE server.\n if ('urls' in b) {\n const s = coerceTurnServer(b)\n return s ? [s] : []\n }\n const ice = b['iceServers']\n if (!ice) return []\n // B: array of servers.\n if (Array.isArray(ice)) {\n return ice\n .map((entry) => (entry && typeof entry === 'object' ? coerceTurnServer(entry as Record<string, unknown>) : null))\n .filter((s): s is TurnServer => s !== null)\n }\n // A: nested single object.\n if (typeof ice === 'object') {\n const s = coerceTurnServer(ice as Record<string, unknown>)\n return s ? [s] : []\n }\n return []\n}\n\nfunction coerceTurnServer(obj: Record<string, unknown>): TurnServer | null {\n const urls = obj['urls']\n if (!urls) return null\n let normalisedUrls: string | string[]\n if (Array.isArray(urls)) {\n const strs = urls.filter((u): u is string => typeof u === 'string')\n if (strs.length === 0) return null\n normalisedUrls = strs\n } else if (typeof urls === 'string') {\n normalisedUrls = urls\n } else {\n return null\n }\n const username = obj['username']\n const credential = obj['credential']\n return {\n urls: normalisedUrls,\n ...(typeof username === 'string' ? { username } : {}),\n ...(typeof credential === 'string' ? { credential } : {}),\n }\n}\n\nconst TURN_API_BASE = 'https://rtc.live.cloudflare.com/v1/turn/keys'\n\n/**\n * Credential lifetime (seconds) requested from Cloudflare. The hub\n * refreshes well before expiry; consumers (WebRTC sessions) get them\n * fresh per ICE-server enumeration.\n */\nconst TTL_SECONDS = 86_400\n/** Refresh slightly before TTL/2 so a slow request never returns expired creds. */\nconst REFRESH_BEFORE_MS = (TTL_SECONDS * 1000) / 2\n\ninterface CachedServers {\n readonly servers: readonly TurnServer[]\n readonly fetchedAt: number\n}\n\n/**\n * Cloudflare TURN/STUN provider.\n *\n * Implements the `turn-provider` capability. On each call, returns the\n * cached short-lived ICE servers (refreshed in the background ~12h\n * before expiry). The first call kicks off the initial fetch — until\n * it completes, an empty list is returned so the WebRTC layer falls\n * back to other providers / no TURN.\n */\nexport class CloudflareTurnService {\n readonly id = 'cloudflare-turn'\n readonly name = 'Cloudflare TURN'\n\n private cached: CachedServers | null = null\n /** In-flight refresh promise — coalesces concurrent fetches. */\n private inflight: Promise<readonly TurnServer[]> | null = null\n\n constructor(\n private readonly config: CloudflareTurnConfig,\n private readonly logger: IScopedLogger,\n ) {}\n\n /**\n * One-shot probe used by the settings UI's \"Test credentials\" button.\n * Bypasses the cache so the operator sees what the LIVE Cloudflare\n * response is for the currently-typed credentials. Returns a\n * structured success/failure envelope so the form can render a clear\n * outcome (✓ with server count, ✗ with status + body).\n */\n async testCredentials(): Promise<TestCredentialsResult> {\n if (!this.config.accountId || !this.config.apiToken) {\n return {\n ok: false,\n body: 'TURN Key ID and API Token must both be filled before testing.',\n }\n }\n const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`\n let res: Response\n try {\n res = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.config.apiToken}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ ttl: TTL_SECONDS }),\n })\n } catch (err) {\n return {\n ok: false,\n body: `Network error: ${err instanceof Error ? err.message : String(err)}`,\n }\n }\n if (!res.ok) {\n const text = await res.text().catch(() => '')\n const looksLikeKeyMismatch = res.status === 404 && text.includes('cannot find specified key')\n const hint = looksLikeKeyMismatch\n ? 'The \"TURN Key ID\" does not match any of your TURN apps. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown on that page (not the Cloudflare Account ID).'\n : res.status === 401 || res.status === 403\n ? 'Authentication failed — the API Token is invalid or doesn\\'t belong to this TURN app. Regenerate the API Token from the TURN app\\'s page.'\n : undefined\n return {\n ok: false,\n status: res.status,\n body: text.slice(0, 500),\n ...(hint ? { hint } : {}),\n }\n }\n let body: unknown\n try {\n body = await res.json()\n } catch {\n return { ok: false, body: 'Response was not JSON' }\n }\n const servers = parseCloudflareTurnResponse(body)\n if (servers.length === 0) {\n return {\n ok: false,\n body: `Could not parse ICE servers from response. Raw: ${JSON.stringify(body).slice(0, 400)}`,\n }\n }\n const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0)\n const hasCredentials = servers.some((s) => s.username && s.credential)\n const firstServer = servers[0]\n const firstUrl = firstServer\n ? (Array.isArray(firstServer.urls) ? firstServer.urls[0] : firstServer.urls)\n : undefined\n return {\n ok: true,\n urlCount: totalUrls,\n hasCredentials,\n ...(firstUrl ? { firstUrl: String(firstUrl) } : {}),\n }\n }\n\n async getTurnServers(): Promise<readonly TurnServer[]> {\n if (!this.config.accountId || !this.config.apiToken) {\n this.logger.warn('Cloudflare TURN: credentials not configured — skipping fetch')\n return []\n }\n // Cache hit: return immediately + refresh in the background when\n // we're past the halfway mark.\n if (this.cached) {\n const age = Date.now() - this.cached.fetchedAt\n if (age < REFRESH_BEFORE_MS) return this.cached.servers\n // Stale — kick off refresh but serve the (still-valid) cache to\n // the caller. The next caller after refresh completes gets fresh.\n void this.refresh()\n return this.cached.servers\n }\n // Cold: await the first fetch so the consumer doesn't see []\n // when credentials ARE configured.\n return this.refresh()\n }\n\n private async refresh(): Promise<readonly TurnServer[]> {\n if (this.inflight) return this.inflight\n this.inflight = this.doFetch().finally(() => { this.inflight = null })\n return this.inflight\n }\n\n private async doFetch(): Promise<readonly TurnServer[]> {\n const url = `${TURN_API_BASE}/${encodeURIComponent(this.config.accountId)}/credentials/generate-ice-servers`\n this.logger.debug('Fetching TURN servers from Cloudflare', { meta: { url } })\n let res: Response\n try {\n res = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.config.apiToken}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ ttl: TTL_SECONDS }),\n })\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n this.logger.error('Cloudflare TURN fetch failed (network error)', { meta: { error: msg } })\n return this.cached?.servers ?? []\n }\n if (!res.ok) {\n const text = await res.text().catch(() => '')\n // 404 \"cannot find specified key\" is the operator-actionable hint:\n // the TURN Key ID field was filled with the wrong value (most often\n // the Cloudflare Account ID instead of the per-app Token ID).\n const looksLikeKeyMismatch = res.status === 404 && text.includes('cannot find specified key')\n // Mask all but the first/last 4 chars of the key so it's safe to\n // log + paste into a bug report.\n const k = this.config.accountId\n const keyMasked = k.length > 8 ? `${k.slice(0, 4)}…${k.slice(-4)}` : '***'\n this.logger.error('Cloudflare TURN fetch failed', {\n meta: {\n status: res.status,\n statusText: res.statusText,\n body: text.slice(0, 500),\n url: `${TURN_API_BASE}/${keyMasked}/credentials/generate-ice-servers`,\n keyMasked,\n tokenLength: this.config.apiToken.length,\n hint: looksLikeKeyMismatch\n ? 'The \"TURN Key ID\" field does NOT accept the Cloudflare Account ID. Open dash.cloudflare.com → Calls → TURN App and copy the Token ID shown there (a separate hex id from your account).'\n : undefined,\n },\n })\n return this.cached?.servers ?? []\n }\n let body: unknown\n try {\n body = await res.json()\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n this.logger.error('Cloudflare TURN response not JSON', { meta: { error: msg } })\n return this.cached?.servers ?? []\n }\n const servers = parseCloudflareTurnResponse(body)\n if (servers.length === 0) {\n this.logger.error('Cloudflare TURN response had no usable ICE servers', {\n meta: { body: JSON.stringify(body).slice(0, 800) },\n })\n return this.cached?.servers ?? []\n }\n this.cached = { servers, fetchedAt: Date.now() }\n const totalUrls = servers.reduce((n, s) => n + (Array.isArray(s.urls) ? s.urls.length : 1), 0)\n const hasCredentials = servers.some((s) => s.username && s.credential)\n this.logger.info('Cloudflare TURN servers fetched', {\n meta: {\n serverCount: servers.length,\n urlCount: totalUrls,\n hasCredentials,\n ttlSeconds: TTL_SECONDS,\n },\n })\n return servers\n }\n}\n","/**\n * Cloudflare TURN — customActions catalog.\n *\n * testCredentials — fire one fetch against the configured TURN Key ID\n * + API Token and return either the success payload\n * (server count + first URL) or the error reason.\n * The settings form exposes a \"Test\" button below\n * the credential inputs so operators can validate\n * before going to the WebRTC consumer side.\n */\nimport { z } from 'zod'\nimport { customAction, defineCustomActions } from '@camstack/types'\n\nconst TestResultSchema = z.discriminatedUnion('ok', [\n z.object({\n ok: z.literal(true),\n urlCount: z.number(),\n hasCredentials: z.boolean(),\n firstUrl: z.string().optional(),\n }),\n z.object({\n ok: z.literal(false),\n status: z.number().optional(),\n body: z.string(),\n hint: z.string().optional(),\n }),\n])\n\nexport const cloudflareTurnActions = defineCustomActions({\n testCredentials: customAction(\n // `.optional()` because tRPC elides `{}` inputs on the wire.\n z.object({}).optional(),\n TestResultSchema,\n { kind: 'mutation' },\n ),\n})\n\nexport type CloudflareTurnActions = typeof cloudflareTurnActions\n","import type { AddonInitResult } from '@camstack/types'\nimport { BaseAddon, turnProviderCapability } from '@camstack/types'\nimport { CloudflareTurnService } from './cloudflare-turn'\nimport type { CloudflareTurnConfig } from './cloudflare-turn'\nimport { cloudflareTurnActions, type CloudflareTurnActions } from './cloudflare-turn-actions'\n\n/**\n * Static `customActions` re-export — picked up by the addon-service-factory\n * when this addon runs in a forked group-runner (the factory reads the\n * module's named export so the Moleculer actions get registered with the\n * canonical schemas). Without this the hub's custom-action dispatch\n * surfaces \"no custom action X\" because the catalog isn't visible.\n */\nexport { cloudflareTurnActions as customActions } from './cloudflare-turn-actions'\n\n/**\n * Settings redesign Phase 3: cloudflare-turn is node-level credentials\n * storage. Implements `getGlobalSettings`.\n */\nexport class CloudflareTurnAddon extends BaseAddon<CloudflareTurnConfig> {\n private service: CloudflareTurnService | null = null\n\n constructor() {\n super({ apiToken: '', accountId: '' })\n }\n\n protected async onInitialize(): Promise<AddonInitResult<CloudflareTurnActions>> {\n this.service = new CloudflareTurnService(this.config, this.ctx.logger)\n this.ctx.logger.info('Cloudflare TURN initialized')\n return {\n providers: [{ capability: turnProviderCapability, provider: this.service }],\n customActions: cloudflareTurnActions,\n actionHandlers: {\n testCredentials: async () => {\n // Bypass the cache so the test reflects the LIVE config (operator\n // just edited it). The service stays the canonical impl.\n const svc = new CloudflareTurnService(this.config, this.ctx.logger)\n return svc.testCredentials()\n },\n },\n }\n }\n\n protected async onShutdown(): Promise<void> {\n this.service = null\n }\n\n getService(): CloudflareTurnService {\n if (!this.service) throw new Error('Cloudflare TURN not initialized')\n return this.service\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [\n {\n id: 'credentials',\n title: 'Cloudflare Credentials',\n description: 'API credentials for fetching TURN relay tokens from Cloudflare Realtime.',\n immediate: true,\n fields: [\n {\n type: 'info' as const,\n key: 'turnHelp',\n label: 'How to get the TURN Key ID + API Token',\n format: 'html' as const,\n content:\n '<ul>' +\n '<li>Open ' +\n '<a href=\"https://dash.cloudflare.com/?to=/:account/calls\" ' +\n 'target=\"_blank\" rel=\"noopener noreferrer\">' +\n 'Cloudflare Dashboard → Calls</a> (the product\\'s URL path is still ' +\n '<code>/calls</code> after the Realtime rebrand).</li>' +\n '<li>Pick the <strong>TURN</strong> section and click ' +\n '<strong>Create TURN App</strong> (or pick an existing one).</li>' +\n '<li>Cloudflare generates a pair: <strong>Token ID</strong> ' +\n '(hex string, also shown as \"Key ID\" in some docs) and an ' +\n '<strong>API Token</strong>. Both are app-scoped — they are ' +\n '<em>NOT</em> the same as your Cloudflare Account ID.</li>' +\n '<li>Paste them in the fields below.</li>' +\n '<li>The integration calls ' +\n '<code>rtc.live.cloudflare.com/v1/turn/keys/<TokenId>/credentials/generate-ice-servers</code> ' +\n 'to mint short-lived ICE credentials per WebRTC session.</li>' +\n '</ul>',\n variant: 'info' as const,\n },\n this.field({\n type: 'text',\n key: 'accountId',\n label: 'TURN Key ID',\n description: 'Token ID of the Realtime TURN app (NOT your Cloudflare Account ID — it\\'s a separate hex id generated when you create the TURN app).',\n placeholder: 'a1b2c3d4e5f6...',\n required: true,\n }),\n this.field({\n type: 'password',\n key: 'apiToken',\n label: 'API Token',\n description: 'The API Token shown alongside the Token ID when you created the TURN app. App-scoped — separate from any Cloudflare global API token.',\n showToggle: true,\n required: true,\n }),\n {\n type: 'addon-action-button' as const,\n key: 'testCredentials',\n label: 'Test credentials',\n description: 'Hit the Cloudflare TURN API with the current Key ID + API Token. On success shows the URL count + whether credentials came back; on failure shows the raw response with an operator hint.',\n addonId: 'cloudflare-turn',\n action: 'testCredentials',\n buttonLabel: 'Test',\n successMessage: '✓ Fetched {urlCount} URL(s) — credentials: {hasCredentials}',\n },\n {\n type: 'info' as const,\n key: 'usageHelp',\n label: 'Usage & free quota',\n format: 'html' as const,\n content:\n '<p>Cloudflare\\'s TURN credentials endpoint we use (<code>Calls:Read</code> ' +\n 'scope) does not return usage / remaining-free-credit. Check current ' +\n 'consumption + free quota in the dashboard:</p>' +\n '<ul>' +\n '<li><a href=\"https://dash.cloudflare.com/?to=/:account/calls\" ' +\n 'target=\"_blank\" rel=\"noopener noreferrer\">' +\n 'Cloudflare Dashboard → Calls</a> — overview of TURN apps with usage ' +\n 'graphs and current-month consumption (the URL still says ' +\n '<code>/calls</code> after the \"Realtime\" rebrand).</li>' +\n '</ul>' +\n '<p class=\"text-foreground-subtle text-xs\">Wiring a live counter requires ' +\n 'a separate API token with <code>Account → Analytics → Read</code> scope ' +\n 'and Cloudflare\\'s GraphQL Analytics endpoint. Filed as a follow-up; ' +\n 'use the dashboard link above in the meantime.</p>',\n variant: 'info' as const,\n },\n ],\n },\n ],\n })\n }\n}\n"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -3,4 +3,6 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
3
3
|
const cloudflareTurn_addon = require("./cloudflare-turn.addon.js");
|
|
4
4
|
exports.CloudflareTurnAddon = cloudflareTurn_addon.CloudflareTurnAddon;
|
|
5
5
|
exports.CloudflareTurnService = cloudflareTurn_addon.CloudflareTurnService;
|
|
6
|
+
exports.cloudflareTurnActions = cloudflareTurn_addon.customActions;
|
|
7
|
+
exports.customActions = cloudflareTurn_addon.customActions;
|
|
6
8
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { CloudflareTurnAddon, C } from "./cloudflare-turn.addon.mjs";
|
|
1
|
+
import { CloudflareTurnAddon, C, customActions, customActions as customActions2 } from "./cloudflare-turn.addon.mjs";
|
|
2
2
|
export {
|
|
3
3
|
CloudflareTurnAddon,
|
|
4
|
-
C as CloudflareTurnService
|
|
4
|
+
C as CloudflareTurnService,
|
|
5
|
+
customActions as cloudflareTurnActions,
|
|
6
|
+
customActions2 as customActions
|
|
5
7
|
};
|
|
6
8
|
//# sourceMappingURL=index.mjs.map
|