@botcord/botcord 0.1.1
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/README.md +171 -0
- package/index.ts +109 -0
- package/openclaw.plugin.json +68 -0
- package/package.json +61 -0
- package/skills/botcord/SKILL.md +460 -0
- package/src/channel.ts +446 -0
- package/src/client.ts +752 -0
- package/src/commands/bind.ts +30 -0
- package/src/commands/healthcheck.ts +160 -0
- package/src/commands/register.ts +449 -0
- package/src/commands/token.ts +42 -0
- package/src/config.ts +92 -0
- package/src/credentials.ts +125 -0
- package/src/crypto.ts +155 -0
- package/src/hub-url.ts +41 -0
- package/src/inbound.ts +532 -0
- package/src/loop-risk.ts +413 -0
- package/src/poller.ts +70 -0
- package/src/reply-dispatcher.ts +59 -0
- package/src/runtime.ts +25 -0
- package/src/sanitize.ts +43 -0
- package/src/session-key.ts +59 -0
- package/src/tools/account.ts +94 -0
- package/src/tools/bind.ts +96 -0
- package/src/tools/coin-format.ts +12 -0
- package/src/tools/contacts.ts +120 -0
- package/src/tools/directory.ts +104 -0
- package/src/tools/messaging.ts +234 -0
- package/src/tools/notify.ts +55 -0
- package/src/tools/payment-transfer.ts +153 -0
- package/src/tools/payment.ts +384 -0
- package/src/tools/rooms.ts +228 -0
- package/src/tools/subscription.ts +249 -0
- package/src/tools/topics.ts +106 -0
- package/src/topic-tracker.ts +204 -0
- package/src/types.ts +273 -0
- package/src/ws-client.ts +187 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for BotCord channel.
|
|
3
|
+
* The runtime still understands both flat and account-mapped config shapes,
|
|
4
|
+
* but the plugin currently operates in single-account mode.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
readCredentialFileData,
|
|
8
|
+
resolveCredentialsFilePath,
|
|
9
|
+
} from "./credentials.js";
|
|
10
|
+
import type { BotCordAccountConfig, BotCordChannelConfig } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const SINGLE_ACCOUNT_ONLY_MESSAGE =
|
|
13
|
+
"BotCord currently supports only a single configured account. Multi-account support is planned for a future update.";
|
|
14
|
+
|
|
15
|
+
export function resolveChannelConfig(cfg: any): BotCordChannelConfig {
|
|
16
|
+
return (cfg?.channels?.botcord ?? {}) as BotCordChannelConfig;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hydrateAccountConfig(acct: BotCordAccountConfig): BotCordAccountConfig {
|
|
20
|
+
const credentialsFile = acct.credentialsFile
|
|
21
|
+
? resolveCredentialsFilePath(acct.credentialsFile)
|
|
22
|
+
: undefined;
|
|
23
|
+
const fileData = readCredentialFileData(credentialsFile);
|
|
24
|
+
const inlineData = Object.fromEntries(
|
|
25
|
+
Object.entries(acct).filter(([, value]) => value !== undefined),
|
|
26
|
+
) as BotCordAccountConfig;
|
|
27
|
+
return {
|
|
28
|
+
...fileData,
|
|
29
|
+
...inlineData,
|
|
30
|
+
credentialsFile,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve all account configs from either flat or account-mapped config. */
|
|
35
|
+
export function resolveAccounts(
|
|
36
|
+
channelCfg: BotCordChannelConfig,
|
|
37
|
+
): Record<string, BotCordAccountConfig> {
|
|
38
|
+
if (channelCfg.accounts && Object.keys(channelCfg.accounts).length > 0) {
|
|
39
|
+
return Object.fromEntries(
|
|
40
|
+
Object.entries(channelCfg.accounts).map(([accountId, acct]) => [
|
|
41
|
+
accountId,
|
|
42
|
+
hydrateAccountConfig(acct),
|
|
43
|
+
]),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
// Single-account fallback
|
|
47
|
+
return {
|
|
48
|
+
default: hydrateAccountConfig({
|
|
49
|
+
enabled: channelCfg.enabled,
|
|
50
|
+
credentialsFile: channelCfg.credentialsFile,
|
|
51
|
+
hubUrl: channelCfg.hubUrl,
|
|
52
|
+
agentId: channelCfg.agentId,
|
|
53
|
+
keyId: channelCfg.keyId,
|
|
54
|
+
privateKey: channelCfg.privateKey,
|
|
55
|
+
publicKey: channelCfg.publicKey,
|
|
56
|
+
deliveryMode: channelCfg.deliveryMode,
|
|
57
|
+
pollIntervalMs: channelCfg.pollIntervalMs,
|
|
58
|
+
allowFrom: channelCfg.allowFrom,
|
|
59
|
+
notifySession: channelCfg.notifySession,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveAccountConfig(
|
|
65
|
+
cfg: any,
|
|
66
|
+
accountId?: string,
|
|
67
|
+
): BotCordAccountConfig {
|
|
68
|
+
const channelCfg = resolveChannelConfig(cfg);
|
|
69
|
+
const accounts = resolveAccounts(channelCfg);
|
|
70
|
+
const id = accountId || "default";
|
|
71
|
+
return accounts[id] || accounts[Object.keys(accounts)[0]] || {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isAccountConfigured(acct: BotCordAccountConfig): boolean {
|
|
75
|
+
return !!(acct.hubUrl && acct.agentId && acct.keyId && acct.privateKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function countAccounts(cfg: any): number {
|
|
79
|
+
const channelCfg = resolveChannelConfig(cfg);
|
|
80
|
+
return Object.keys(resolveAccounts(channelCfg)).length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getSingleAccountModeError(cfg: any): string | null {
|
|
84
|
+
return countAccounts(cfg) > 1 ? SINGLE_ACCOUNT_ONLY_MESSAGE : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Display prefix for logs and messages. */
|
|
88
|
+
export function displayPrefix(accountId: string, cfg: any): string {
|
|
89
|
+
const total = countAccounts(cfg);
|
|
90
|
+
if (total <= 1 && accountId === "default") return "BotCord";
|
|
91
|
+
return `BotCord:${accountId}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { derivePublicKey } from "./crypto.js";
|
|
5
|
+
import { normalizeAndValidateHubUrl } from "./hub-url.js";
|
|
6
|
+
import type { BotCordAccountConfig } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface StoredBotCordCredentials {
|
|
9
|
+
version: 1;
|
|
10
|
+
hubUrl: string;
|
|
11
|
+
agentId: string;
|
|
12
|
+
keyId: string;
|
|
13
|
+
privateKey: string;
|
|
14
|
+
publicKey: string;
|
|
15
|
+
displayName?: string;
|
|
16
|
+
savedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeCredentialValue(raw: any, keys: string[]): string | undefined {
|
|
20
|
+
for (const key of keys) {
|
|
21
|
+
const value = raw?.[key];
|
|
22
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveCredentialsFilePath(credentialsFile: string): string {
|
|
28
|
+
if (credentialsFile === "~") return os.homedir();
|
|
29
|
+
if (credentialsFile.startsWith("~/")) {
|
|
30
|
+
return path.join(os.homedir(), credentialsFile.slice(2));
|
|
31
|
+
}
|
|
32
|
+
return path.isAbsolute(credentialsFile)
|
|
33
|
+
? credentialsFile
|
|
34
|
+
: path.resolve(credentialsFile);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultCredentialsFile(agentId: string): string {
|
|
38
|
+
return path.join(os.homedir(), ".botcord", "credentials", `${agentId}.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readCredentialSource(credentialsFile: string): Record<string, unknown> {
|
|
42
|
+
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
throw new Error(`Unable to read BotCord credentials file "${resolved}": ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadStoredCredentials(credentialsFile: string): StoredBotCordCredentials {
|
|
51
|
+
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
52
|
+
const raw = readCredentialSource(resolved);
|
|
53
|
+
const hubUrl = normalizeCredentialValue(raw, ["hubUrl", "hub_url", "hub"]);
|
|
54
|
+
const agentId = normalizeCredentialValue(raw, ["agentId", "agent_id"]);
|
|
55
|
+
const keyId = normalizeCredentialValue(raw, ["keyId", "key_id"]);
|
|
56
|
+
const privateKey = normalizeCredentialValue(raw, ["privateKey", "private_key"]);
|
|
57
|
+
const publicKey = normalizeCredentialValue(raw, ["publicKey", "public_key"]);
|
|
58
|
+
const displayName = normalizeCredentialValue(raw, ["displayName", "display_name"]);
|
|
59
|
+
const savedAt = normalizeCredentialValue(raw, ["savedAt", "saved_at"]);
|
|
60
|
+
|
|
61
|
+
if (!hubUrl) throw new Error(`BotCord credentials file "${resolved}" is missing hubUrl`);
|
|
62
|
+
if (!agentId) throw new Error(`BotCord credentials file "${resolved}" is missing agentId`);
|
|
63
|
+
if (!keyId) throw new Error(`BotCord credentials file "${resolved}" is missing keyId`);
|
|
64
|
+
if (!privateKey) throw new Error(`BotCord credentials file "${resolved}" is missing privateKey`);
|
|
65
|
+
|
|
66
|
+
const derivedPublicKey = derivePublicKey(privateKey);
|
|
67
|
+
if (publicKey && publicKey !== derivedPublicKey) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`BotCord credentials file "${resolved}" has a publicKey that does not match privateKey`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let normalizedHubUrl: string;
|
|
74
|
+
try {
|
|
75
|
+
normalizedHubUrl = normalizeAndValidateHubUrl(hubUrl);
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
throw new Error(`BotCord credentials file "${resolved}" has an invalid hubUrl: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
hubUrl: normalizedHubUrl,
|
|
83
|
+
agentId,
|
|
84
|
+
keyId,
|
|
85
|
+
privateKey,
|
|
86
|
+
publicKey: publicKey || derivedPublicKey,
|
|
87
|
+
displayName,
|
|
88
|
+
savedAt: savedAt || new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readCredentialFileData(credentialsFile?: string): Partial<BotCordAccountConfig> {
|
|
93
|
+
if (!credentialsFile) return {};
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const raw = loadStoredCredentials(credentialsFile);
|
|
97
|
+
return {
|
|
98
|
+
hubUrl: raw.hubUrl,
|
|
99
|
+
agentId: raw.agentId,
|
|
100
|
+
keyId: raw.keyId,
|
|
101
|
+
privateKey: raw.privateKey,
|
|
102
|
+
publicKey: raw.publicKey,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function writeCredentialsFile(
|
|
110
|
+
credentialsFile: string,
|
|
111
|
+
credentials: StoredBotCordCredentials,
|
|
112
|
+
): string {
|
|
113
|
+
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
114
|
+
const normalizedCredentials = {
|
|
115
|
+
...credentials,
|
|
116
|
+
hubUrl: normalizeAndValidateHubUrl(credentials.hubUrl),
|
|
117
|
+
};
|
|
118
|
+
mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 });
|
|
119
|
+
writeFileSync(resolved, JSON.stringify(normalizedCredentials, null, 2) + "\n", {
|
|
120
|
+
encoding: "utf8",
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
});
|
|
123
|
+
chmodSync(resolved, 0o600);
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 signing for BotCord protocol.
|
|
3
|
+
* Zero npm dependencies — uses Node.js built-in crypto module.
|
|
4
|
+
* Ported from botcord-skill/skill/botcord-crypto.mjs.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
createHash,
|
|
8
|
+
createPublicKey,
|
|
9
|
+
createPrivateKey,
|
|
10
|
+
generateKeyPairSync,
|
|
11
|
+
sign,
|
|
12
|
+
randomUUID,
|
|
13
|
+
} from "node:crypto";
|
|
14
|
+
import type { BotCordMessageEnvelope, BotCordSignature, MessageType } from "./types.js";
|
|
15
|
+
|
|
16
|
+
// ── JCS (RFC 8785) canonicalization ─────────────────────────────
|
|
17
|
+
export function jcsCanonicalize(value: unknown): string | undefined {
|
|
18
|
+
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
19
|
+
if (typeof value === "number") {
|
|
20
|
+
if (Object.is(value, -0)) return "0";
|
|
21
|
+
return JSON.stringify(value);
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return "[" + value.map((v) => jcsCanonicalize(v)).join(",") + "]";
|
|
26
|
+
if (typeof value === "object") {
|
|
27
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
for (const k of keys) {
|
|
30
|
+
const v = (value as Record<string, unknown>)[k];
|
|
31
|
+
if (v === undefined) continue;
|
|
32
|
+
parts.push(JSON.stringify(k) + ":" + jcsCanonicalize(v));
|
|
33
|
+
}
|
|
34
|
+
return "{" + parts.join(",") + "}";
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Build Node.js KeyObject from raw 32-byte seed ───────────────
|
|
40
|
+
function privateKeyFromSeed(seed: Buffer): ReturnType<typeof createPrivateKey> {
|
|
41
|
+
const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
42
|
+
return createPrivateKey({
|
|
43
|
+
key: Buffer.concat([prefix, seed]),
|
|
44
|
+
format: "der",
|
|
45
|
+
type: "pkcs8",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Payload hash ────────────────────────────────────────────────
|
|
50
|
+
export function computePayloadHash(payload: Record<string, unknown>): string {
|
|
51
|
+
const canonical = jcsCanonicalize(payload)!;
|
|
52
|
+
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
53
|
+
return `sha256:${digest}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Sign challenge ──────────────────────────────────────────────
|
|
57
|
+
export function signChallenge(privateKeyB64: string, challengeB64: string): string {
|
|
58
|
+
const pk = privateKeyFromSeed(Buffer.from(privateKeyB64, "base64"));
|
|
59
|
+
const sig = sign(null, Buffer.from(challengeB64, "base64"), pk);
|
|
60
|
+
return sig.toString("base64");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function derivePublicKey(privateKeyB64: string): string {
|
|
64
|
+
const privateKey = privateKeyFromSeed(Buffer.from(privateKeyB64, "base64"));
|
|
65
|
+
const publicKey = createPublicKey(privateKey);
|
|
66
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
67
|
+
return Buffer.from(pubDer.subarray(-32)).toString("base64");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Build and sign a full message envelope ──────────────────────
|
|
71
|
+
export function buildSignedEnvelope(params: {
|
|
72
|
+
from: string;
|
|
73
|
+
to: string;
|
|
74
|
+
type: MessageType;
|
|
75
|
+
payload: Record<string, unknown>;
|
|
76
|
+
privateKey: string; // base64 Ed25519 seed
|
|
77
|
+
keyId: string;
|
|
78
|
+
replyTo?: string | null;
|
|
79
|
+
ttlSec?: number;
|
|
80
|
+
topic?: string | null;
|
|
81
|
+
goal?: string | null;
|
|
82
|
+
}): BotCordMessageEnvelope {
|
|
83
|
+
const {
|
|
84
|
+
from,
|
|
85
|
+
to,
|
|
86
|
+
type,
|
|
87
|
+
payload,
|
|
88
|
+
privateKey,
|
|
89
|
+
keyId,
|
|
90
|
+
replyTo = null,
|
|
91
|
+
ttlSec = 3600,
|
|
92
|
+
topic = null,
|
|
93
|
+
goal = null,
|
|
94
|
+
} = params;
|
|
95
|
+
|
|
96
|
+
const msgId = randomUUID();
|
|
97
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
98
|
+
const payloadHash = computePayloadHash(payload);
|
|
99
|
+
|
|
100
|
+
// Build signing input (newline-joined fields)
|
|
101
|
+
const parts = [
|
|
102
|
+
"a2a/0.1",
|
|
103
|
+
msgId,
|
|
104
|
+
String(ts),
|
|
105
|
+
from,
|
|
106
|
+
to,
|
|
107
|
+
String(type),
|
|
108
|
+
replyTo || "",
|
|
109
|
+
String(ttlSec),
|
|
110
|
+
payloadHash,
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const pk = privateKeyFromSeed(Buffer.from(privateKey, "base64"));
|
|
114
|
+
const sigValue = sign(null, Buffer.from(parts.join("\n")), pk);
|
|
115
|
+
|
|
116
|
+
const sig: BotCordSignature = {
|
|
117
|
+
alg: "ed25519",
|
|
118
|
+
key_id: keyId,
|
|
119
|
+
value: sigValue.toString("base64"),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
v: "a2a/0.1",
|
|
124
|
+
msg_id: msgId,
|
|
125
|
+
ts,
|
|
126
|
+
from,
|
|
127
|
+
to,
|
|
128
|
+
type,
|
|
129
|
+
reply_to: replyTo,
|
|
130
|
+
ttl_sec: ttlSec,
|
|
131
|
+
topic,
|
|
132
|
+
goal,
|
|
133
|
+
payload,
|
|
134
|
+
payload_hash: payloadHash,
|
|
135
|
+
sig,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Keygen ──────────────────────────────────────────────────────
|
|
140
|
+
export function generateKeypair(): {
|
|
141
|
+
privateKey: string;
|
|
142
|
+
publicKey: string;
|
|
143
|
+
pubkeyFormatted: string;
|
|
144
|
+
} {
|
|
145
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
146
|
+
const privDer = privateKey.export({ type: "pkcs8", format: "der" });
|
|
147
|
+
const privB64 = Buffer.from(privDer.subarray(-32)).toString("base64");
|
|
148
|
+
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
149
|
+
const pubB64 = Buffer.from(pubDer.subarray(-32)).toString("base64");
|
|
150
|
+
return {
|
|
151
|
+
privateKey: privB64,
|
|
152
|
+
publicKey: pubB64,
|
|
153
|
+
pubkeyFormatted: `ed25519:${pubB64}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/hub-url.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
2
|
+
|
|
3
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
4
|
+
const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
5
|
+
return LOOPBACK_HOSTS.has(normalized) || normalized.endsWith(".localhost");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeAndValidateHubUrl(hubUrl: string): string {
|
|
9
|
+
const trimmed = hubUrl.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
throw new Error("BotCord hubUrl is required");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let parsed: URL;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(trimmed);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`BotCord hubUrl must be a valid absolute URL: ${hubUrl}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
22
|
+
throw new Error("BotCord hubUrl must use http:// or https://");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"BotCord hubUrl must use https:// unless it targets localhost, 127.0.0.1, or ::1 for local development",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return trimmed.replace(/\/$/, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildHubWebSocketUrl(hubUrl: string): string {
|
|
35
|
+
const parsed = new URL(normalizeAndValidateHubUrl(hubUrl));
|
|
36
|
+
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
|
|
37
|
+
parsed.search = "";
|
|
38
|
+
parsed.hash = "";
|
|
39
|
+
parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/hub/ws`;
|
|
40
|
+
return parsed.toString();
|
|
41
|
+
}
|