webrtc-tree 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/RTCTreeClient.d.ts +33 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +205 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/index.mjs +168 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +314 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +276 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server/RTCTreeCoordinator.d.ts +38 -0
- package/dist/server/RTCTreeCoordinator.test.d.ts +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +137 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +110 -0
- package/dist/server/index.mjs.map +1 -0
- package/jest.config.js +6 -0
- package/package.json +53 -0
- package/src/client/RTCTreeClient.ts +208 -0
- package/src/client/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/RTCTreeCoordinator.test.ts +83 -0
- package/src/server/RTCTreeCoordinator.ts +153 -0
- package/src/server/index.ts +1 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface RTCTreeNode {
|
|
2
|
+
children: string[];
|
|
3
|
+
parent: string | null;
|
|
4
|
+
layer: number;
|
|
5
|
+
}
|
|
6
|
+
export interface RoomConfig {
|
|
7
|
+
maxNodesPerLayer: number[];
|
|
8
|
+
}
|
|
9
|
+
export declare class RTCTreeCoordinator {
|
|
10
|
+
private trees;
|
|
11
|
+
private configs;
|
|
12
|
+
/**
|
|
13
|
+
* 建立一個新的直播房間拓撲結構
|
|
14
|
+
* @param roomId 房間 ID
|
|
15
|
+
* @param streamerPeerId 直播主的 Peer ID
|
|
16
|
+
* @param config 拓撲設定,例如 maxNodesPerLayer: [1, 4, 8, 16, 64]
|
|
17
|
+
*/
|
|
18
|
+
createRoom(roomId: string, streamerPeerId: string, config: RoomConfig): void;
|
|
19
|
+
/**
|
|
20
|
+
* 新節點加入,透過 BFS 分配最合適的父節點
|
|
21
|
+
* @param roomId 房間 ID
|
|
22
|
+
* @param newPeerId 新節點的 Peer ID
|
|
23
|
+
* @returns 分配到的父節點 Peer ID,若無法分配則回傳 null
|
|
24
|
+
*/
|
|
25
|
+
joinNode(roomId: string, newPeerId: string): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* 節點斷線,將其從樹中移除,並讓其子節點變成孤兒 (等待重新 join)
|
|
28
|
+
*/
|
|
29
|
+
removeNode(roomId: string, deadPeerId: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* 報告節點失效 (Self-Healing 觸發點)
|
|
32
|
+
*/
|
|
33
|
+
reportDeadNode(roomId: string, deadPeerId: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* 取得房間目前的樹狀結構 (For Debug/Monitor)
|
|
36
|
+
*/
|
|
37
|
+
getTree(roomId: string): Record<string, RTCTreeNode> | null;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './RTCTreeCoordinator';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/server/index.ts
|
|
21
|
+
var server_exports = {};
|
|
22
|
+
__export(server_exports, {
|
|
23
|
+
RTCTreeCoordinator: () => RTCTreeCoordinator
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(server_exports);
|
|
26
|
+
|
|
27
|
+
// src/server/RTCTreeCoordinator.ts
|
|
28
|
+
var RTCTreeCoordinator = class {
|
|
29
|
+
constructor() {
|
|
30
|
+
// roomId -> peerId -> Node Data
|
|
31
|
+
this.trees = {};
|
|
32
|
+
this.configs = {};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 建立一個新的直播房間拓撲結構
|
|
36
|
+
* @param roomId 房間 ID
|
|
37
|
+
* @param streamerPeerId 直播主的 Peer ID
|
|
38
|
+
* @param config 拓撲設定,例如 maxNodesPerLayer: [1, 4, 8, 16, 64]
|
|
39
|
+
*/
|
|
40
|
+
createRoom(roomId, streamerPeerId, config) {
|
|
41
|
+
this.configs[roomId] = config;
|
|
42
|
+
this.trees[roomId] = {};
|
|
43
|
+
this.trees[roomId][streamerPeerId] = { children: [], parent: null, layer: 0 };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 新節點加入,透過 BFS 分配最合適的父節點
|
|
47
|
+
* @param roomId 房間 ID
|
|
48
|
+
* @param newPeerId 新節點的 Peer ID
|
|
49
|
+
* @returns 分配到的父節點 Peer ID,若無法分配則回傳 null
|
|
50
|
+
*/
|
|
51
|
+
joinNode(roomId, newPeerId) {
|
|
52
|
+
const tree = this.trees[roomId];
|
|
53
|
+
const config = this.configs[roomId];
|
|
54
|
+
if (!tree || !config) return null;
|
|
55
|
+
let rootId = null;
|
|
56
|
+
for (const [id, node] of Object.entries(tree)) {
|
|
57
|
+
if (node.layer === 0) {
|
|
58
|
+
rootId = id;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!rootId) return null;
|
|
63
|
+
if (tree[newPeerId]) {
|
|
64
|
+
this.removeNode(roomId, newPeerId);
|
|
65
|
+
}
|
|
66
|
+
const layerCounts = {};
|
|
67
|
+
for (const node of Object.values(tree)) {
|
|
68
|
+
layerCounts[node.layer] = (layerCounts[node.layer] || 0) + 1;
|
|
69
|
+
}
|
|
70
|
+
const queue = [rootId];
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const currentId = queue.shift();
|
|
73
|
+
const currentNode = tree[currentId];
|
|
74
|
+
const currentLayer = currentNode.layer;
|
|
75
|
+
const nextLayer = currentLayer + 1;
|
|
76
|
+
if (nextLayer >= config.maxNodesPerLayer.length) {
|
|
77
|
+
queue.push(...currentNode.children);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const nextLayerMax = config.maxNodesPerLayer[nextLayer];
|
|
81
|
+
const currentNextLayerCount = layerCounts[nextLayer] || 0;
|
|
82
|
+
if (currentNextLayerCount >= nextLayerMax) {
|
|
83
|
+
queue.push(...currentNode.children);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const currentLayerMax = config.maxNodesPerLayer[currentLayer];
|
|
87
|
+
const maxChildrenPerNode = Math.floor(nextLayerMax / currentLayerMax) || 1;
|
|
88
|
+
if (currentNode.children.length < maxChildrenPerNode) {
|
|
89
|
+
currentNode.children.push(newPeerId);
|
|
90
|
+
tree[newPeerId] = {
|
|
91
|
+
children: [],
|
|
92
|
+
parent: currentId,
|
|
93
|
+
layer: nextLayer
|
|
94
|
+
};
|
|
95
|
+
return currentId;
|
|
96
|
+
}
|
|
97
|
+
queue.push(...currentNode.children);
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 節點斷線,將其從樹中移除,並讓其子節點變成孤兒 (等待重新 join)
|
|
103
|
+
*/
|
|
104
|
+
removeNode(roomId, deadPeerId) {
|
|
105
|
+
const tree = this.trees[roomId];
|
|
106
|
+
if (!tree) return;
|
|
107
|
+
const deadNode = tree[deadPeerId];
|
|
108
|
+
if (!deadNode) return;
|
|
109
|
+
if (deadNode.parent && tree[deadNode.parent]) {
|
|
110
|
+
const parentNode = tree[deadNode.parent];
|
|
111
|
+
parentNode.children = parentNode.children.filter((id) => id !== deadPeerId);
|
|
112
|
+
}
|
|
113
|
+
for (const childId of deadNode.children) {
|
|
114
|
+
if (tree[childId]) {
|
|
115
|
+
tree[childId].parent = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
delete tree[deadPeerId];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 報告節點失效 (Self-Healing 觸發點)
|
|
122
|
+
*/
|
|
123
|
+
reportDeadNode(roomId, deadPeerId) {
|
|
124
|
+
this.removeNode(roomId, deadPeerId);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 取得房間目前的樹狀結構 (For Debug/Monitor)
|
|
128
|
+
*/
|
|
129
|
+
getTree(roomId) {
|
|
130
|
+
return this.trees[roomId] || null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
134
|
+
0 && (module.exports = {
|
|
135
|
+
RTCTreeCoordinator
|
|
136
|
+
});
|
|
137
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts","../../src/server/RTCTreeCoordinator.ts"],"sourcesContent":["export * from './RTCTreeCoordinator';\n","export interface RTCTreeNode {\n children: string[];\n parent: string | null;\n layer: number;\n}\n\nexport interface RoomConfig {\n maxNodesPerLayer: number[]; // e.g. [1, 4, 8, 16, 64]\n}\n\nexport class RTCTreeCoordinator {\n // roomId -> peerId -> Node Data\n private trees: Record<string, Record<string, RTCTreeNode>> = {};\n private configs: Record<string, RoomConfig> = {};\n\n /**\n * 建立一個新的直播房間拓撲結構\n * @param roomId 房間 ID\n * @param streamerPeerId 直播主的 Peer ID\n * @param config 拓撲設定,例如 maxNodesPerLayer: [1, 4, 8, 16, 64]\n */\n public createRoom(roomId: string, streamerPeerId: string, config: RoomConfig): void {\n this.configs[roomId] = config;\n this.trees[roomId] = {};\n // Streamer is at layer 0\n this.trees[roomId][streamerPeerId] = { children: [], parent: null, layer: 0 };\n }\n\n /**\n * 新節點加入,透過 BFS 分配最合適的父節點\n * @param roomId 房間 ID\n * @param newPeerId 新節點的 Peer ID\n * @returns 分配到的父節點 Peer ID,若無法分配則回傳 null\n */\n public joinNode(roomId: string, newPeerId: string): string | null {\n const tree = this.trees[roomId];\n const config = this.configs[roomId];\n if (!tree || !config) return null;\n\n // 尋找樹根 (layer 0 的節點,通常是 streamer)\n let rootId: string | null = null;\n for (const [id, node] of Object.entries(tree)) {\n if (node.layer === 0) {\n rootId = id;\n break;\n }\n }\n\n if (!rootId) return null;\n\n // 如果該節點已經在樹中,先將其從原本的位置移除 (防止重複加入或狀態不一致)\n if (tree[newPeerId]) {\n this.removeNode(roomId, newPeerId);\n }\n\n // 計算目前每一層的總節點數\n const layerCounts: Record<number, number> = {};\n for (const node of Object.values(tree)) {\n layerCounts[node.layer] = (layerCounts[node.layer] || 0) + 1;\n }\n\n // BFS 尋找未滿載的節點\n const queue: string[] = [rootId];\n \n while (queue.length > 0) {\n const currentId = queue.shift()!;\n const currentNode = tree[currentId];\n const currentLayer = currentNode.layer;\n \n // 下一層的索引\n const nextLayer = currentLayer + 1;\n\n // 如果已經達到最大層數設定,該節點不能再有子節點\n if (nextLayer >= config.maxNodesPerLayer.length) {\n // Continue searching in queue\n queue.push(...currentNode.children);\n continue;\n }\n\n // 檢查下一層是否已經達到整體上限\n const nextLayerMax = config.maxNodesPerLayer[nextLayer];\n const currentNextLayerCount = layerCounts[nextLayer] || 0;\n\n if (currentNextLayerCount >= nextLayerMax) {\n // 下一層已經滿了,把目前節點的子節點加入 queue 繼續尋找更下層\n queue.push(...currentNode.children);\n continue;\n }\n\n // 如果下一層還沒滿,那我們要決定「目前這個節點」還能不能接客\n // 算法:該層平均每個節點可以接的子節點數\n // 例如 layer 1 最大 4 人,layer 2 最大 8 人,代表 layer 1 每個節點最多接 8/4 = 2 人\n const currentLayerMax = config.maxNodesPerLayer[currentLayer];\n const maxChildrenPerNode = Math.floor(nextLayerMax / currentLayerMax) || 1;\n\n if (currentNode.children.length < maxChildrenPerNode) {\n // 找到可以接客的節點了!\n currentNode.children.push(newPeerId);\n tree[newPeerId] = {\n children: [],\n parent: currentId,\n layer: nextLayer\n };\n return currentId;\n }\n\n // 目前節點滿了,把它的小孩加進 queue\n queue.push(...currentNode.children);\n }\n\n return null; // 樹已滿或無法分配\n }\n\n /**\n * 節點斷線,將其從樹中移除,並讓其子節點變成孤兒 (等待重新 join)\n */\n public removeNode(roomId: string, deadPeerId: string): void {\n const tree = this.trees[roomId];\n if (!tree) return;\n\n const deadNode = tree[deadPeerId];\n if (!deadNode) return;\n\n // 將自己從父節點的 children 中移除\n if (deadNode.parent && tree[deadNode.parent]) {\n const parentNode = tree[deadNode.parent];\n parentNode.children = parentNode.children.filter(id => id !== deadPeerId);\n }\n\n // 將子節點的 parent 設為 null (它們需要重新連線)\n for (const childId of deadNode.children) {\n if (tree[childId]) {\n tree[childId].parent = null;\n }\n }\n\n delete tree[deadPeerId];\n }\n\n /**\n * 報告節點失效 (Self-Healing 觸發點)\n */\n public reportDeadNode(roomId: string, deadPeerId: string): void {\n this.removeNode(roomId, deadPeerId);\n }\n\n /**\n * 取得房間目前的樹狀結構 (For Debug/Monitor)\n */\n public getTree(roomId: string): Record<string, RTCTreeNode> | null {\n return this.trees[roomId] || null;\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// src/server/RTCTreeCoordinator.ts
|
|
2
|
+
var RTCTreeCoordinator = class {
|
|
3
|
+
constructor() {
|
|
4
|
+
// roomId -> peerId -> Node Data
|
|
5
|
+
this.trees = {};
|
|
6
|
+
this.configs = {};
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 建立一個新的直播房間拓撲結構
|
|
10
|
+
* @param roomId 房間 ID
|
|
11
|
+
* @param streamerPeerId 直播主的 Peer ID
|
|
12
|
+
* @param config 拓撲設定,例如 maxNodesPerLayer: [1, 4, 8, 16, 64]
|
|
13
|
+
*/
|
|
14
|
+
createRoom(roomId, streamerPeerId, config) {
|
|
15
|
+
this.configs[roomId] = config;
|
|
16
|
+
this.trees[roomId] = {};
|
|
17
|
+
this.trees[roomId][streamerPeerId] = { children: [], parent: null, layer: 0 };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 新節點加入,透過 BFS 分配最合適的父節點
|
|
21
|
+
* @param roomId 房間 ID
|
|
22
|
+
* @param newPeerId 新節點的 Peer ID
|
|
23
|
+
* @returns 分配到的父節點 Peer ID,若無法分配則回傳 null
|
|
24
|
+
*/
|
|
25
|
+
joinNode(roomId, newPeerId) {
|
|
26
|
+
const tree = this.trees[roomId];
|
|
27
|
+
const config = this.configs[roomId];
|
|
28
|
+
if (!tree || !config) return null;
|
|
29
|
+
let rootId = null;
|
|
30
|
+
for (const [id, node] of Object.entries(tree)) {
|
|
31
|
+
if (node.layer === 0) {
|
|
32
|
+
rootId = id;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!rootId) return null;
|
|
37
|
+
if (tree[newPeerId]) {
|
|
38
|
+
this.removeNode(roomId, newPeerId);
|
|
39
|
+
}
|
|
40
|
+
const layerCounts = {};
|
|
41
|
+
for (const node of Object.values(tree)) {
|
|
42
|
+
layerCounts[node.layer] = (layerCounts[node.layer] || 0) + 1;
|
|
43
|
+
}
|
|
44
|
+
const queue = [rootId];
|
|
45
|
+
while (queue.length > 0) {
|
|
46
|
+
const currentId = queue.shift();
|
|
47
|
+
const currentNode = tree[currentId];
|
|
48
|
+
const currentLayer = currentNode.layer;
|
|
49
|
+
const nextLayer = currentLayer + 1;
|
|
50
|
+
if (nextLayer >= config.maxNodesPerLayer.length) {
|
|
51
|
+
queue.push(...currentNode.children);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const nextLayerMax = config.maxNodesPerLayer[nextLayer];
|
|
55
|
+
const currentNextLayerCount = layerCounts[nextLayer] || 0;
|
|
56
|
+
if (currentNextLayerCount >= nextLayerMax) {
|
|
57
|
+
queue.push(...currentNode.children);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const currentLayerMax = config.maxNodesPerLayer[currentLayer];
|
|
61
|
+
const maxChildrenPerNode = Math.floor(nextLayerMax / currentLayerMax) || 1;
|
|
62
|
+
if (currentNode.children.length < maxChildrenPerNode) {
|
|
63
|
+
currentNode.children.push(newPeerId);
|
|
64
|
+
tree[newPeerId] = {
|
|
65
|
+
children: [],
|
|
66
|
+
parent: currentId,
|
|
67
|
+
layer: nextLayer
|
|
68
|
+
};
|
|
69
|
+
return currentId;
|
|
70
|
+
}
|
|
71
|
+
queue.push(...currentNode.children);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 節點斷線,將其從樹中移除,並讓其子節點變成孤兒 (等待重新 join)
|
|
77
|
+
*/
|
|
78
|
+
removeNode(roomId, deadPeerId) {
|
|
79
|
+
const tree = this.trees[roomId];
|
|
80
|
+
if (!tree) return;
|
|
81
|
+
const deadNode = tree[deadPeerId];
|
|
82
|
+
if (!deadNode) return;
|
|
83
|
+
if (deadNode.parent && tree[deadNode.parent]) {
|
|
84
|
+
const parentNode = tree[deadNode.parent];
|
|
85
|
+
parentNode.children = parentNode.children.filter((id) => id !== deadPeerId);
|
|
86
|
+
}
|
|
87
|
+
for (const childId of deadNode.children) {
|
|
88
|
+
if (tree[childId]) {
|
|
89
|
+
tree[childId].parent = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
delete tree[deadPeerId];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 報告節點失效 (Self-Healing 觸發點)
|
|
96
|
+
*/
|
|
97
|
+
reportDeadNode(roomId, deadPeerId) {
|
|
98
|
+
this.removeNode(roomId, deadPeerId);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 取得房間目前的樹狀結構 (For Debug/Monitor)
|
|
102
|
+
*/
|
|
103
|
+
getTree(roomId) {
|
|
104
|
+
return this.trees[roomId] || null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
export {
|
|
108
|
+
RTCTreeCoordinator
|
|
109
|
+
};
|
|
110
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/RTCTreeCoordinator.ts"],"sourcesContent":["export interface RTCTreeNode {\n children: string[];\n parent: string | null;\n layer: number;\n}\n\nexport interface RoomConfig {\n maxNodesPerLayer: number[]; // e.g. [1, 4, 8, 16, 64]\n}\n\nexport class RTCTreeCoordinator {\n // roomId -> peerId -> Node Data\n private trees: Record<string, Record<string, RTCTreeNode>> = {};\n private configs: Record<string, RoomConfig> = {};\n\n /**\n * 建立一個新的直播房間拓撲結構\n * @param roomId 房間 ID\n * @param streamerPeerId 直播主的 Peer ID\n * @param config 拓撲設定,例如 maxNodesPerLayer: [1, 4, 8, 16, 64]\n */\n public createRoom(roomId: string, streamerPeerId: string, config: RoomConfig): void {\n this.configs[roomId] = config;\n this.trees[roomId] = {};\n // Streamer is at layer 0\n this.trees[roomId][streamerPeerId] = { children: [], parent: null, layer: 0 };\n }\n\n /**\n * 新節點加入,透過 BFS 分配最合適的父節點\n * @param roomId 房間 ID\n * @param newPeerId 新節點的 Peer ID\n * @returns 分配到的父節點 Peer ID,若無法分配則回傳 null\n */\n public joinNode(roomId: string, newPeerId: string): string | null {\n const tree = this.trees[roomId];\n const config = this.configs[roomId];\n if (!tree || !config) return null;\n\n // 尋找樹根 (layer 0 的節點,通常是 streamer)\n let rootId: string | null = null;\n for (const [id, node] of Object.entries(tree)) {\n if (node.layer === 0) {\n rootId = id;\n break;\n }\n }\n\n if (!rootId) return null;\n\n // 如果該節點已經在樹中,先將其從原本的位置移除 (防止重複加入或狀態不一致)\n if (tree[newPeerId]) {\n this.removeNode(roomId, newPeerId);\n }\n\n // 計算目前每一層的總節點數\n const layerCounts: Record<number, number> = {};\n for (const node of Object.values(tree)) {\n layerCounts[node.layer] = (layerCounts[node.layer] || 0) + 1;\n }\n\n // BFS 尋找未滿載的節點\n const queue: string[] = [rootId];\n \n while (queue.length > 0) {\n const currentId = queue.shift()!;\n const currentNode = tree[currentId];\n const currentLayer = currentNode.layer;\n \n // 下一層的索引\n const nextLayer = currentLayer + 1;\n\n // 如果已經達到最大層數設定,該節點不能再有子節點\n if (nextLayer >= config.maxNodesPerLayer.length) {\n // Continue searching in queue\n queue.push(...currentNode.children);\n continue;\n }\n\n // 檢查下一層是否已經達到整體上限\n const nextLayerMax = config.maxNodesPerLayer[nextLayer];\n const currentNextLayerCount = layerCounts[nextLayer] || 0;\n\n if (currentNextLayerCount >= nextLayerMax) {\n // 下一層已經滿了,把目前節點的子節點加入 queue 繼續尋找更下層\n queue.push(...currentNode.children);\n continue;\n }\n\n // 如果下一層還沒滿,那我們要決定「目前這個節點」還能不能接客\n // 算法:該層平均每個節點可以接的子節點數\n // 例如 layer 1 最大 4 人,layer 2 最大 8 人,代表 layer 1 每個節點最多接 8/4 = 2 人\n const currentLayerMax = config.maxNodesPerLayer[currentLayer];\n const maxChildrenPerNode = Math.floor(nextLayerMax / currentLayerMax) || 1;\n\n if (currentNode.children.length < maxChildrenPerNode) {\n // 找到可以接客的節點了!\n currentNode.children.push(newPeerId);\n tree[newPeerId] = {\n children: [],\n parent: currentId,\n layer: nextLayer\n };\n return currentId;\n }\n\n // 目前節點滿了,把它的小孩加進 queue\n queue.push(...currentNode.children);\n }\n\n return null; // 樹已滿或無法分配\n }\n\n /**\n * 節點斷線,將其從樹中移除,並讓其子節點變成孤兒 (等待重新 join)\n */\n public removeNode(roomId: string, deadPeerId: string): void {\n const tree = this.trees[roomId];\n if (!tree) return;\n\n const deadNode = tree[deadPeerId];\n if (!deadNode) return;\n\n // 將自己從父節點的 children 中移除\n if (deadNode.parent && tree[deadNode.parent]) {\n const parentNode = tree[deadNode.parent];\n parentNode.children = parentNode.children.filter(id => id !== deadPeerId);\n }\n\n // 將子節點的 parent 設為 null (它們需要重新連線)\n for (const childId of deadNode.children) {\n if (tree[childId]) {\n tree[childId].parent = null;\n }\n }\n\n delete tree[deadPeerId];\n }\n\n /**\n * 報告節點失效 (Self-Healing 觸發點)\n */\n public reportDeadNode(roomId: string, deadPeerId: string): void {\n this.removeNode(roomId, deadPeerId);\n }\n\n /**\n * 取得房間目前的樹狀結構 (For Debug/Monitor)\n */\n public getTree(roomId: string): Record<string, RTCTreeNode> | null {\n return this.trees[roomId] || null;\n }\n}\n"],"mappings":"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","names":[]}
|
package/jest.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webrtc-tree",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Auto-balancing WebRTC Mesh Tree for live streaming",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"require": "./dist/index.cjs",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"require": "./dist/server/index.cjs",
|
|
16
|
+
"import": "./dist/server/index.mjs",
|
|
17
|
+
"types": "./dist/server/index.d.ts"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"require": "./dist/client/index.cjs",
|
|
21
|
+
"import": "./dist/client/index.mjs",
|
|
22
|
+
"types": "./dist/client/index.d.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build:types": "tsc --emitDeclarationOnly",
|
|
27
|
+
"build": "tsup && npm run build:types",
|
|
28
|
+
"test": "jest"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"webrtc",
|
|
32
|
+
"mesh",
|
|
33
|
+
"p2p",
|
|
34
|
+
"live",
|
|
35
|
+
"streaming",
|
|
36
|
+
"tree",
|
|
37
|
+
"auto-balancing"
|
|
38
|
+
],
|
|
39
|
+
"author": "",
|
|
40
|
+
"license": "ISC",
|
|
41
|
+
"type": "commonjs",
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"peerjs": "^1.5.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/jest": "^30.0.0",
|
|
47
|
+
"@types/node": "^26.0.0",
|
|
48
|
+
"jest": "^30.4.2",
|
|
49
|
+
"ts-jest": "^29.4.11",
|
|
50
|
+
"tsup": "^8.5.1",
|
|
51
|
+
"typescript": "^6.0.3"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import Peer, { MediaConnection } from 'peerjs';
|
|
2
|
+
|
|
3
|
+
export interface RTCTreeClientOptions {
|
|
4
|
+
onStreamReceived?: (stream: MediaStream) => void;
|
|
5
|
+
onStatusChange?: (status: string) => void;
|
|
6
|
+
onError?: (error: any) => void;
|
|
7
|
+
fetchParentIdFn?: () => Promise<string | null>; // Client uses this to ask Server for a parent
|
|
8
|
+
reportDeadFn?: (deadPeerId: string) => Promise<void>; // Client uses this to tell Server a node is dead
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class RTCTreeClient {
|
|
12
|
+
private peer: Peer | null = null;
|
|
13
|
+
private myPeerId: string | null = null;
|
|
14
|
+
private myStream: MediaStream | null = null;
|
|
15
|
+
private activeCalls: Record<string, MediaConnection> = {};
|
|
16
|
+
private parentConnection: MediaConnection | null = null;
|
|
17
|
+
|
|
18
|
+
// State
|
|
19
|
+
private isStreamer: boolean = false;
|
|
20
|
+
private isReconnecting: boolean = false;
|
|
21
|
+
|
|
22
|
+
constructor(private options: RTCTreeClientOptions) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 初始化為直播主 (Streamer)
|
|
26
|
+
* @param stream 本地攝影機/麥克風的 MediaStream
|
|
27
|
+
* @param maxChildren 第一層最大觀眾數 (Layer 1 Capacity)
|
|
28
|
+
* @returns 建立完成後的 Peer ID
|
|
29
|
+
*/
|
|
30
|
+
public initStreamer(stream: MediaStream, maxChildren: number = 4): Promise<string> {
|
|
31
|
+
this.isStreamer = true;
|
|
32
|
+
this.myStream = stream;
|
|
33
|
+
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
this.peer = new Peer();
|
|
36
|
+
|
|
37
|
+
this.peer.on('open', (id) => {
|
|
38
|
+
this.myPeerId = id;
|
|
39
|
+
this.options.onStatusChange?.("直播已開始");
|
|
40
|
+
resolve(id);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.peer.on('error', (err) => {
|
|
44
|
+
this.options.onError?.(err);
|
|
45
|
+
reject(err);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// 監聽觀眾連線請求
|
|
49
|
+
this.peer.on('connection', (conn) => {
|
|
50
|
+
conn.on('data', (data: any) => {
|
|
51
|
+
if (data === 'VIEWER_READY') {
|
|
52
|
+
if (Object.keys(this.activeCalls).length < maxChildren) {
|
|
53
|
+
const call = this.peer!.call(conn.peer, this.myStream!);
|
|
54
|
+
this.activeCalls[conn.peer] = call;
|
|
55
|
+
} else {
|
|
56
|
+
conn.send({ type: 'REJECT_FULL' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
conn.on('close', () => {
|
|
62
|
+
if (this.activeCalls[conn.peer]) {
|
|
63
|
+
this.activeCalls[conn.peer].close();
|
|
64
|
+
delete this.activeCalls[conn.peer];
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 初始化為觀眾 (Viewer)
|
|
73
|
+
* @param maxChildren 轉發最大觀眾數 (Layer N Capacity)
|
|
74
|
+
*/
|
|
75
|
+
public initViewer(maxChildren: number = 4): Promise<string> {
|
|
76
|
+
this.isStreamer = false;
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
this.peer = new Peer();
|
|
80
|
+
|
|
81
|
+
this.peer.on('open', async (id) => {
|
|
82
|
+
this.myPeerId = id;
|
|
83
|
+
this.options.onStatusChange?.("正在分配節點...");
|
|
84
|
+
resolve(id);
|
|
85
|
+
|
|
86
|
+
// 開始加入流程
|
|
87
|
+
await this.connectToMesh();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.peer.on('error', (err) => {
|
|
91
|
+
this.options.onError?.(err);
|
|
92
|
+
reject(err);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 當我們自己也是別人的 parent 時,處理下層觀眾連線
|
|
96
|
+
this.peer.on('connection', (conn) => {
|
|
97
|
+
conn.on('data', (data: any) => {
|
|
98
|
+
if (data === 'VIEWER_READY') {
|
|
99
|
+
if (Object.keys(this.activeCalls).length < maxChildren && this.myStream) {
|
|
100
|
+
const call = this.peer!.call(conn.peer, this.myStream);
|
|
101
|
+
this.activeCalls[conn.peer] = call;
|
|
102
|
+
} else {
|
|
103
|
+
conn.send({ type: 'REJECT_FULL' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
conn.on('close', () => {
|
|
109
|
+
if (this.activeCalls[conn.peer]) {
|
|
110
|
+
this.activeCalls[conn.peer].close();
|
|
111
|
+
delete this.activeCalls[conn.peer];
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 接收上層傳來的影像
|
|
117
|
+
this.peer.on('call', (call) => {
|
|
118
|
+
this.options.onStatusChange?.("接收影像中...");
|
|
119
|
+
this.parentConnection = call;
|
|
120
|
+
call.answer();
|
|
121
|
+
|
|
122
|
+
call.on('stream', (remoteStream) => {
|
|
123
|
+
this.myStream = remoteStream;
|
|
124
|
+
this.options.onStatusChange?.("");
|
|
125
|
+
this.options.onStreamReceived?.(remoteStream);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
call.on('close', () => {
|
|
129
|
+
// 上層斷線!啟動 Self-Healing
|
|
130
|
+
this.handleParentDisconnect(call.peer);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async connectToMesh(): Promise<void> {
|
|
137
|
+
if (!this.options.fetchParentIdFn) {
|
|
138
|
+
throw new Error("fetchParentIdFn is required for viewers");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const targetPeerId = await this.options.fetchParentIdFn();
|
|
143
|
+
if (!targetPeerId) {
|
|
144
|
+
this.options.onStatusChange?.("暫無可用節點,請稍後重試");
|
|
145
|
+
// 可以加上 retry 機制
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.options.onStatusChange?.("連線中...");
|
|
150
|
+
const conn = this.peer!.connect(targetPeerId);
|
|
151
|
+
|
|
152
|
+
const timeoutId = setTimeout(() => {
|
|
153
|
+
conn.close();
|
|
154
|
+
this.handleParentDisconnect(targetPeerId);
|
|
155
|
+
}, 5000);
|
|
156
|
+
|
|
157
|
+
conn.on('open', () => {
|
|
158
|
+
clearTimeout(timeoutId);
|
|
159
|
+
conn.send('VIEWER_READY');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
conn.on('data', (data: any) => {
|
|
163
|
+
if (data && data.type === 'REJECT_FULL') {
|
|
164
|
+
clearTimeout(timeoutId);
|
|
165
|
+
conn.close();
|
|
166
|
+
this.handleParentDisconnect(targetPeerId);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
conn.on('close', () => {
|
|
171
|
+
clearTimeout(timeoutId);
|
|
172
|
+
this.handleParentDisconnect(targetPeerId);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
conn.on('error', () => {
|
|
176
|
+
clearTimeout(timeoutId);
|
|
177
|
+
this.handleParentDisconnect(targetPeerId);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
} catch (e) {
|
|
181
|
+
this.options.onStatusChange?.("連線失敗");
|
|
182
|
+
console.error(e);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async handleParentDisconnect(deadPeerId: string) {
|
|
187
|
+
if (this.isReconnecting) return;
|
|
188
|
+
this.isReconnecting = true;
|
|
189
|
+
this.options.onStatusChange?.("上層節點斷線,重新尋找路徑...");
|
|
190
|
+
|
|
191
|
+
if (this.options.reportDeadFn) {
|
|
192
|
+
await this.options.reportDeadFn(deadPeerId).catch(console.error);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 延遲一下避免大量 request
|
|
196
|
+
setTimeout(async () => {
|
|
197
|
+
this.isReconnecting = false;
|
|
198
|
+
await this.connectToMesh();
|
|
199
|
+
}, 2000);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
public destroy() {
|
|
203
|
+
if (this.peer) {
|
|
204
|
+
this.peer.destroy();
|
|
205
|
+
this.peer = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './RTCTreeClient';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RTCTreeCoordinator } from './RTCTreeCoordinator';
|
|
2
|
+
|
|
3
|
+
describe('RTCTreeCoordinator', () => {
|
|
4
|
+
let coordinator: RTCTreeCoordinator;
|
|
5
|
+
const roomId = 'test-room';
|
|
6
|
+
const streamerId = 'peer-streamer';
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
coordinator = new RTCTreeCoordinator();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('should create a room and streamer should be at layer 0', () => {
|
|
13
|
+
coordinator.createRoom(roomId, streamerId, { maxNodesPerLayer: [1, 4, 8, 16] });
|
|
14
|
+
const tree = coordinator.getTree(roomId);
|
|
15
|
+
expect(tree).toBeDefined();
|
|
16
|
+
expect(tree![streamerId]).toBeDefined();
|
|
17
|
+
expect(tree![streamerId].layer).toBe(0);
|
|
18
|
+
expect(tree![streamerId].children.length).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('should distribute viewers according to maxNodesPerLayer [1, 4, 8]', () => {
|
|
22
|
+
// 1 Streamer
|
|
23
|
+
// Layer 1: Max 4
|
|
24
|
+
// Layer 2: Max 8 (each Layer 1 node handles 2 children)
|
|
25
|
+
coordinator.createRoom(roomId, streamerId, { maxNodesPerLayer: [1, 4, 8] });
|
|
26
|
+
|
|
27
|
+
// Join 4 viewers
|
|
28
|
+
for (let i = 1; i <= 4; i++) {
|
|
29
|
+
const parent = coordinator.joinNode(roomId, `peer-v${i}`);
|
|
30
|
+
expect(parent).toBe(streamerId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tree = coordinator.getTree(roomId);
|
|
34
|
+
expect(tree![streamerId].children.length).toBe(4);
|
|
35
|
+
|
|
36
|
+
// Join 5th viewer, should go to layer 2
|
|
37
|
+
const parent5 = coordinator.joinNode(roomId, `peer-v5`);
|
|
38
|
+
expect(parent5).not.toBe(streamerId);
|
|
39
|
+
expect(['peer-v1', 'peer-v2', 'peer-v3', 'peer-v4']).toContain(parent5);
|
|
40
|
+
|
|
41
|
+
// Join up to 12 total viewers (4 in L1, 8 in L2)
|
|
42
|
+
for (let i = 6; i <= 12; i++) {
|
|
43
|
+
coordinator.joinNode(roomId, `peer-v${i}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Now L1 has 4 nodes, L2 has 8 nodes. Total 13 nodes (including streamer)
|
|
47
|
+
const treeState = coordinator.getTree(roomId)!;
|
|
48
|
+
let l1Count = 0;
|
|
49
|
+
let l2Count = 0;
|
|
50
|
+
for (const node of Object.values(treeState)) {
|
|
51
|
+
if (node.layer === 1) l1Count++;
|
|
52
|
+
if (node.layer === 2) l2Count++;
|
|
53
|
+
}
|
|
54
|
+
expect(l1Count).toBe(4);
|
|
55
|
+
expect(l2Count).toBe(8);
|
|
56
|
+
|
|
57
|
+
// Join 13th viewer. Tree should be FULL because config only specifies up to L2 (maxNodesPerLayer length is 3)
|
|
58
|
+
const parent13 = coordinator.joinNode(roomId, 'peer-v13');
|
|
59
|
+
expect(parent13).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('should self-heal when a node is removed', () => {
|
|
63
|
+
coordinator.createRoom(roomId, streamerId, { maxNodesPerLayer: [1, 4] });
|
|
64
|
+
coordinator.joinNode(roomId, 'peer-v1');
|
|
65
|
+
coordinator.joinNode(roomId, 'peer-v2');
|
|
66
|
+
|
|
67
|
+
let tree = coordinator.getTree(roomId)!;
|
|
68
|
+
expect(tree[streamerId].children).toEqual(['peer-v1', 'peer-v2']);
|
|
69
|
+
|
|
70
|
+
// Remove v1
|
|
71
|
+
coordinator.reportDeadNode(roomId, 'peer-v1');
|
|
72
|
+
tree = coordinator.getTree(roomId)!;
|
|
73
|
+
|
|
74
|
+
expect(tree['peer-v1']).toBeUndefined();
|
|
75
|
+
expect(tree[streamerId].children).toEqual(['peer-v2']);
|
|
76
|
+
|
|
77
|
+
// Re-join v1 (simulate reconnection)
|
|
78
|
+
const parent = coordinator.joinNode(roomId, 'peer-v1');
|
|
79
|
+
expect(parent).toBe(streamerId);
|
|
80
|
+
tree = coordinator.getTree(roomId)!;
|
|
81
|
+
expect(tree[streamerId].children).toEqual(['peer-v2', 'peer-v1']);
|
|
82
|
+
});
|
|
83
|
+
});
|