在 JavaScript 应用中嵌入 Kaigi
Kaigi 允许应用程序创建一个人对一个的音频/视频会议,其生命周期通过 Iroha. 浏览器仍然处理媒体 WebRTC, 在 Torii 和 Kaigi 指示提供持久的会议记录,加密信号传输元数据,私人名单支持和使用事件.
本教程显示了 Iroha Demo JavaScript应用程序所使用的最小集成模式:
- 转载者创建 WebRTC 的报价和答案
- 申请桥标签和提交 Kaigi 交易
- 简单的邀请链接只包含调用 ID 和秘密的邀请.
- 主机观察 Torii 进行加密参与者的答案
这些示例使用 TypeScript 并被编写以使它们可以运行在Electron,一个安全后端的浏览器或一个带钱包扩展的网页应用程序中.
预先条件
你需要:
- 一个具有 Kaigi 能力的 Torii 终端点
- 寄宿人的账户和客人的帐户
- 通过安全应用程序桥梁或钱包获取每个帐户的签名密钥
- 浏览器摄像头/麦克风权限
- Node.js 20+ 如果您直接使用 JavaScript 示范或本地
@iroha/iroha-js绑定
为了获得完整的工作参考,在 Iroha 来源检查旁边克隆示范:
mkdir iroha-wallet-workspace
cd iroha-wallet-workspace
git clone https://github.com/hyperledger-iroha/iroha.git
git clone https://github.com/soramitsu/iroha-demo-javascript.git
cd iroha/javascript/iroha_js
npm install
npm run build:native
npm run build:dist
cd ../../../iroha-demo-javascript
npm install
npm run dev使用演示 @iroha/iroha-js 从兄弟姐妹 Iroha 它的源存储库. file: 如果本土的绑定变化,重建它根据 iroha/javascript/iroha_js; 清洁包装目录不包含需要的货物工作空间 npm run build:native.
在在 TAIRA 上进行现场会议之前,请检查演示程序依赖于的公共 Torii 表面:
TAIRA=https://taira.sora.org
curl -fsS "$TAIRA/health"
curl -fsS "$TAIRA/v1/kaigi/relays"
curl -fsS "$TAIRA/v1/kaigi/relays/health"这些命令验证 TAIRA 是现场的,并且 Kaigi 继电远程测量可用.它们不提交 Kaigi 交易.一个真正的CreateKaigi或JoinKaigi测试需要资助 TAIRA 账户和通过演示桥或其他钱包支的桥签名.
建筑
保持 Kaigi 集成分为三个层:
| 层 | 责任 |
|---|---|
| UI | 邀请链接显示,媒体控制方式 |
| WebRTC | RTCPeerConnection,当地媒体,报价和答案描述 |
| Iroha 桥 | 签名, CreateKaigi, JoinKaigi, EndKaigi,信号投票 |
应用程序桥梁可以是电子预装 API,钱包扩展或后端终点. 它应该暴露在一个小的表面上 UI:
type KaigiMeetingPrivacy = "private" | "transparent";
type KaigiPeerIdentityReveal = "Hidden" | "RevealAfterJoin";
type KaigiSignalKeyPair = {
publicKeyBase64Url: string;
privateKeyBase64Url: string;
};
type KaigiDescription = {
type: "offer" | "answer";
sdp: string;
};
type KaigiMeeting = {
callId: string;
meetingCode: string;
title?: string;
hostAccountId?: string;
hostDisplayName?: string;
hostParticipantId?: string;
hostKaigiPublicKeyBase64Url: string;
scheduledStartMs: number;
expiresAtMs: number;
live: boolean;
ended: boolean;
privacyMode: KaigiMeetingPrivacy;
peerIdentityReveal: KaigiPeerIdentityReveal;
rosterRootHex: string;
offerDescription: { type: "offer"; sdp: string };
};
type KaigiSignal = {
entrypointHash: string;
callId: string;
participantId: string;
participantName: string;
createdAtMs: number;
answerDescription: { type: "answer"; sdp: string };
};
type KaigiBridge = {
generateKaigiSignalKeyPair(): KaigiSignalKeyPair;
createKaigiMeeting(input: {
toriiUrl: string;
chainId: string;
hostAccountId: string;
callId: string;
title?: string;
scheduledStartMs: number;
meetingCode: string;
inviteSecretBase64Url: string;
hostDisplayName: string;
hostParticipantId: string;
hostKaigiPublicKeyBase64Url: string;
offerDescription: { type: "offer"; sdp: string };
privacyMode: KaigiMeetingPrivacy;
peerIdentityReveal: KaigiPeerIdentityReveal;
}): Promise<{ hash: string }>;
getKaigiCall(input: {
toriiUrl: string;
callId: string;
inviteSecretBase64Url: string;
}): Promise<KaigiMeeting>;
joinKaigiMeeting(input: {
toriiUrl: string;
chainId: string;
participantAccountId: string;
callId: string;
hostAccountId?: string;
hostKaigiPublicKeyBase64Url: string;
participantId: string;
participantName: string;
walletIdentity?: string;
roomId: string;
privacyMode: KaigiMeetingPrivacy;
rosterRootHex: string;
answerDescription: { type: "answer"; sdp: string };
}): Promise<{ hash: string }>;
pollKaigiMeetingSignals(input: {
toriiUrl: string;
accountId: string;
callId: string;
hostKaigiKeys: KaigiSignalKeyPair;
afterTimestampMs?: number;
}): Promise<KaigiSignal[]>;
watchKaigiCallEvents(
input: { toriiUrl: string; callId: string },
onEvent: (event: { kind: string; callId: string }) => void | Promise<void>,
): Promise<string>;
endKaigiMeeting(input: {
toriiUrl: string;
chainId: string;
hostAccountId: string;
callId: string;
endedAtMs?: number;
}): Promise<{ hash: string }>;
};在演示应用中,这些桥梁方法是通过 @iroha/iroha-js,本地签名,加密 Kaigi 元数据和 Torii 通话实现的.
邀请助手
使用 Torii- 兼容的电话 IDs 在 domain.dataspace:meeting 在演示中使用 kaigi.universal:<call-name> 对于产生会议.
const KAIGI_WINDOW_MS = 24 * 60 * 60 * 1000;
const base64Url = (bytes: Uint8Array): string =>
btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
export function createInviteSecret(): string {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return base64Url(bytes);
}
export function createMeetingCode(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return base64Url(bytes).toLowerCase();
}
export function buildKaigiCallId(domain: string, meetingCode: string): string {
const qualifiedDomain = domain.includes(".") ? domain : `${domain}.universal`;
const safeCode = meetingCode
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-|-$/g, "");
return `${qualifiedDomain}:kaigi-${safeCode || "meeting"}`;
}
export function buildInviteLink(input: {
callId: string;
inviteSecretBase64Url: string;
}): string {
const call = encodeURIComponent(input.callId);
const secret = encodeURIComponent(input.inviteSecretBase64Url);
return `iroha://kaigi/join?call=${call}&secret=${secret}`;
}
export function parseInviteLink(link: string): {
callId: string;
inviteSecretBase64Url: string;
} {
const url = new URL(link);
const callId = url.searchParams.get("call")?.trim();
const inviteSecretBase64Url = url.searchParams.get("secret")?.trim();
if (!callId || !inviteSecretBase64Url) {
throw new Error("Kaigi invite link is missing call or secret.");
}
return { callId, inviteSecretBase64Url };
}WebRTC 助手
主持人创建了一个报价,通过 CreateKaigi存储它,并保持窗口开放,以便它可以应用客人的答案. 客人获取加密的报价,创建一个答案,并发出答案的帖子与 JoinKaigi.
const rtcConfig: RTCConfiguration = {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
};
export async function openLocalMedia(): Promise<MediaStream> {
return navigator.mediaDevices.getUserMedia({
audio: true,
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 24, max: 30 },
},
});
}
export function createPeer(localStream: MediaStream): RTCPeerConnection {
const peer = new RTCPeerConnection(rtcConfig);
for (const track of localStream.getTracks()) {
peer.addTrack(track, localStream);
}
return peer;
}
async function waitForIceGathering(peer: RTCPeerConnection): Promise<void> {
if (peer.iceGatheringState === "complete") {
return;
}
await new Promise<void>((resolve) => {
const done = () => {
if (peer.iceGatheringState === "complete") {
peer.removeEventListener("icegatheringstatechange", done);
resolve();
}
};
peer.addEventListener("icegatheringstatechange", done);
});
}
export async function createOfferDescription(
peer: RTCPeerConnection,
): Promise<{ type: "offer"; sdp: string }> {
const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
await waitForIceGathering(peer);
const local = peer.localDescription;
if (!local?.sdp || local.type !== "offer") {
throw new Error("WebRTC offer was not created.");
}
return { type: "offer", sdp: local.sdp };
}
export async function createAnswerDescription(
peer: RTCPeerConnection,
offer: { type: "offer"; sdp: string },
): Promise<{ type: "answer"; sdp: string }> {
await peer.setRemoteDescription(offer);
const answer = await peer.createAnswer();
await peer.setLocalDescription(answer);
await waitForIceGathering(peer);
const local = peer.localDescription;
if (!local?.sdp || local.type !== "answer") {
throw new Error("WebRTC answer was not created.");
}
return { type: "answer", sdp: local.sdp };
}通过普通的视频元素将流连接到您的 UI:
export function attachKaigiMedia(input: {
peer: RTCPeerConnection;
localStream: MediaStream;
localVideo: HTMLVideoElement;
remoteVideo: HTMLVideoElement;
}): void {
input.localVideo.srcObject = input.localStream;
const remoteStream = new MediaStream();
input.remoteVideo.srcObject = remoteStream;
input.peer.addEventListener("track", (event) => {
if (event.streams[0]) {
input.remoteVideo.srcObject = event.streams[0];
return;
}
remoteStream.addTrack(event.track);
});
}主持人: 创建一个会议链接
宿主流量:
- 开放式摄像机和麦克风
- 创建一个 Kaigi 信号键对
- 创建一个 WebRTC 的报价
- 提交
CreateKaigi - 分享一个紧的邀请链接
type AccountContext = {
accountId: string;
displayName: string;
};
type KaigiContext = {
bridge: KaigiBridge;
toriiUrl: string;
chainId: string;
};
export async function hostKaigiMeeting(input: {
context: KaigiContext;
account: AccountContext;
title?: string;
privacyMode?: KaigiMeetingPrivacy;
}): Promise<{
callId: string;
inviteLink: string;
peer: RTCPeerConnection;
localStream: MediaStream;
hostKaigiKeys: KaigiSignalKeyPair;
createdAtMs: number;
}> {
const { bridge, toriiUrl, chainId } = input.context;
const privacyMode = input.privacyMode ?? "private";
const scheduledStartMs = Date.now();
const meetingCode = createMeetingCode();
const callId = buildKaigiCallId("kaigi", meetingCode);
const inviteSecretBase64Url = createInviteSecret();
const hostKaigiKeys = bridge.generateKaigiSignalKeyPair();
const localStream = await openLocalMedia();
const peer = createPeer(localStream);
const offerDescription = await createOfferDescription(peer);
await bridge.createKaigiMeeting({
toriiUrl,
chainId,
hostAccountId: input.account.accountId,
callId,
title: input.title,
scheduledStartMs,
meetingCode,
inviteSecretBase64Url,
hostDisplayName: input.account.displayName,
hostParticipantId: "host",
hostKaigiPublicKeyBase64Url: hostKaigiKeys.publicKeyBase64Url,
offerDescription,
privacyMode,
peerIdentityReveal: "Hidden",
});
return {
callId,
inviteLink: buildInviteLink({ callId, inviteSecretBase64Url }),
peer,
localStream,
hostKaigiKeys,
createdAtMs: scheduledStartMs,
};
}在您的 UI 中显示inviteLink.用户可以复制它,在另一个钱包中打开它,或者将其转换为应用程序路线,如:
export function inviteRoute(inviteLink: string): string {
const invite = parseInviteLink(inviteLink);
return `/kaigi?call=${encodeURIComponent(invite.callId)}&secret=${encodeURIComponent(
invite.inviteSecretBase64Url,
)}`;
}嘉宾:参加一次会议
客人的流量:
- 分析邀请
- 从 Torii 获取加密通话报价.
- 创建一个 WebRTC 答案
- 提交
JoinKaigi与加密答案元数据
export async function joinKaigiMeetingFromInvite(input: {
context: KaigiContext;
account: AccountContext;
inviteLink: string;
}): Promise<{
callId: string;
peer: RTCPeerConnection;
localStream: MediaStream;
}> {
const { bridge, toriiUrl, chainId } = input.context;
const { callId, inviteSecretBase64Url } = parseInviteLink(input.inviteLink);
const meeting = await bridge.getKaigiCall({
toriiUrl,
callId,
inviteSecretBase64Url,
});
if (meeting.ended) {
throw new Error("This Kaigi meeting has already ended.");
}
if (Date.now() > meeting.expiresAtMs) {
throw new Error("This Kaigi invite has expired.");
}
const localStream = await openLocalMedia();
const peer = createPeer(localStream);
const answerDescription = await createAnswerDescription(
peer,
meeting.offerDescription,
);
await bridge.joinKaigiMeeting({
toriiUrl,
chainId,
participantAccountId: input.account.accountId,
callId: meeting.callId,
hostAccountId: meeting.hostAccountId,
hostKaigiPublicKeyBase64Url: meeting.hostKaigiPublicKeyBase64Url,
participantId: "guest",
participantName: input.account.displayName,
roomId: meeting.callId,
privacyMode: meeting.privacyMode,
rosterRootHex: meeting.rosterRootHex,
answerDescription,
});
return { callId: meeting.callId, peer, localStream };
}如果会议是透明的,您可以在加入请求中包括一个钱包显示字符串. walletIdentity 除非使用者明确选择披露.
主持人:用客人的答案
在创建现场会议后,主机应该观看 Kaigi 事件并查询加密答案信号.将第一个有效的答案应用于主机的同行连接.
export async function watchForKaigiAnswer(input: {
context: KaigiContext;
hostAccountId: string;
callId: string;
hostKaigiKeys: KaigiSignalKeyPair;
createdAtMs: number;
peer: RTCPeerConnection;
onParticipant?: (signal: KaigiSignal) => void;
}): Promise<string | null> {
const { bridge, toriiUrl } = input.context;
const seenSignals = new Set<string>();
let lastSignalAtMs = input.createdAtMs;
const checkSignals = async (): Promise<boolean> => {
const signals = await bridge.pollKaigiMeetingSignals({
toriiUrl,
accountId: input.hostAccountId,
callId: input.callId,
hostKaigiKeys: input.hostKaigiKeys,
afterTimestampMs: lastSignalAtMs,
});
const next = signals.find(
(signal) => !seenSignals.has(signal.entrypointHash),
);
if (!next) {
return false;
}
seenSignals.add(next.entrypointHash);
lastSignalAtMs = Math.max(lastSignalAtMs, next.createdAtMs);
await input.peer.setRemoteDescription(next.answerDescription);
input.onParticipant?.(next);
return true;
};
if (await checkSignals()) {
return null;
}
return bridge.watchKaigiCallEvents(
{ toriiUrl, callId: input.callId },
async (event) => {
if (event.kind !== "ended") {
await checkSignals();
}
},
);
}存储返回订阅 ID 以便您的 UI 可以在主机关上或导航离开时停止观看器.
会议结束
结束来自创建它的主机帐户的呼叫:
export async function endKaigi(input: {
context: KaigiContext;
hostAccountId: string;
callId: string;
peer?: RTCPeerConnection;
localStream?: MediaStream;
}): Promise<void> {
input.peer?.close();
input.localStream?.getTracks().forEach((track) => track.stop());
await input.context.bridge.endKaigiMeeting({
toriiUrl: input.context.toriiUrl,
chainId: input.context.chainId,
hostAccountId: input.hostAccountId,
callId: input.callId,
endedAtMs: Date.now(),
});
}私人模式的资金
专用 Kaigi 创建,加入和终结操作可能需要屏蔽的 XOR 用于私人入口点费用.您的应用程序应该发现这个错误,并在重新尝试之前提供自我屏蔽行动.
type PrivateKaigiFundingBridge = KaigiBridge & {
getPrivateKaigiConfidentialXorState(input: {
toriiUrl: string;
accountId: string;
}): Promise<{
shieldedBalance: string | null;
transparentBalance: string;
canSelfShield: boolean;
message?: string;
}>;
selfShieldPrivateKaigiXor(input: {
toriiUrl: string;
chainId: string;
accountId: string;
amount: string;
}): Promise<{ hash: string }>;
};
export async function selfShieldForPrivateKaigi(input: {
context: Omit<KaigiContext, "bridge"> & {
bridge: PrivateKaigiFundingBridge;
};
accountId: string;
amount: string;
}): Promise<void> {
const { bridge, toriiUrl, chainId } = input.context;
const state = await bridge.getPrivateKaigiConfidentialXorState({
toriiUrl,
accountId: input.accountId,
});
if (!state.canSelfShield) {
throw new Error(
state.message || "This account cannot self-shield XOR for private Kaigi.",
);
}
await bridge.selfShieldPrivateKaigiXor({
toriiUrl,
chainId,
accountId: input.accountId,
amount: input.amount,
});
}在演示中, UI 提示用户自我屏蔽,然后重新尝试创建或加入原始操作.
手动回归
自动信号取决于现场钱包, Kaigi - 能力的 Torii 路线,以及私人模式中的证明生成.
- 如果
CreateKaigi失败,请显示包含该报价的手动邀请. - 如果
JoinKaigi失败,请显示原始答案包 - 让主机粘贴答案包,然后拨打
setRemoteDescription
手动反弹对调试 WebRTC 有用,但它不提供与直播 Kaigi 流程相同的私人链上信号保证.
测试检查列表
在单元测试中,请模仿桥梁并确认您的 UI 超越预期的 Kaigi 有用负载:
- 主机创建本地媒体,并提交
createKaigiMeeting - 接待者显示
iroha://kaigi/join?call=...&secret=...邀请 - 客人分析邀请,打电话
getKaigiCall,并提交joinKaigiMeeting - 举办民意调查或对答案信号的钟表,并应用答案
- 缺失屏蔽时自闭保护的私人模式提示 XOR
- 当没有现场信号时,出现手动倒车
对于一个完整的参考测试套件,请参见示范应用程序的 Kaigi 视图和预装桥梁测试:
npm test -- tests/kaigiView.spec.ts tests/preloadKaigiBridge.spec.ts
npm run e2e:uiUI 烟雾测试验证了/kaigi路线的效果.真正的媒体测试仍然需要两个资助钱包加上两个窗户或设备,因为交易签名,摄像头,麦克风和 WebRTC 权限因运行时间而不同.
如果您正在对 TAIRA 进行测试,并且呼叫特定的路线返回 404,首先确认主机钱包成功提交 CreateKaigi.在任何特定呼叫之前,继电器健康终端点可获得.
下一步
- 如果您的应用程序有可靠的会计会议时间,请使用
RecordKaigiUsage添加用户记录. - 通过
/v1/kaigi/relays记录和监测继电器,使用继电器表格. - 在操作员仪表板中的表面
KaigiRosterSummary,KaigiUsageSummary和KaigiRelayHealthUpdated事件.