本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。
WebRtc 技术简介
WebRTC(Web 实时通信)是一种使 Web 应用程序之间传递流媒体的技术。他使得用户在浏览器之间交换任意数据的而无需任何中间件。他有如下特点
- p2p 的网络拓扑:
webRtc 相较于传统的流媒体服务器:
- 降低了服务器成本和负载
- 带宽利用率更高
- 减少了数据在传输过程中的暴露风险,提高了通信的安全性。
但是在较大规模的会议场景下,由于客户端压力过大(p2p 连接数会随着用户数量增加而指数增加),一般还是会基于流媒体服务器实现。
- 跨平台和多样的媒体设备兼容
webrtc 通过自身强大的音视频引擎,对各种媒体设备和常见的平台做了广泛的兼容,使得用户基本不用关心媒体流及其加密、传输等功能的具体实现,开箱即用
- 强大的自适应网络能力
webRtc 对各种网络环境进行处理,提供了 p2p 连接中最为棘手的 NAT 穿透能力和高效的网络传输能力
WebRtc 相对来说,还是很底层的技术。他封装了 网络传输 和 媒体设备处理的能力,但是将其他业务场景实现的能力都交给了用户。如我们的 aiccSDK,实际上就是基于 WebRtc 之上继续封装的一整套语音通话的业务逻辑。
网络基础知识
什么是局域网?
局域网是一个局部范围的计算计组,比如家庭网络就是一个小型的局域网,里面包含电脑、手机和平板等,共同连接到一个路由器上。当机房无法上外网时,局域网中的设备之间仍可以通信。
如何在两个局域网用户间建立连接?
设想这样一个问题:在北京和上海各有一台局域网的机器(例如一台是家里的台式机,一台是连接到星巴克 WiFi 的笔记本),二者都是私网 IP 地址,如何让这两台机器通信呢?
最简单的方式当然是在公网上架设一个服务器: 两台机器分别连接到该服务,后者完成双向转发。
什么是NAT?
如果每个接入互联网的机器都需要有一个公网ip 地址,那么全球 IPv4 地址早已不够用,因此人们发明了 NAT(网络地址转换)来缓解这个问题,也就是端口号(这个端口号与服务的端口号有一些区别,服务器的端口号用于区分不同进程,NAT 设备的端口号用于区分本地子网内的不同设备的 IP 地址)。
基于 NAT,大部分机器都使用局域网中的私有 IP 地址,如果它们需要访问公网服务,那么,
-
出向流量:需要经过一台 NAT 设备(最常见的就是路由器),它会对流量进行转换,转换成 NAT 设备的公网 IP+Port,然后再将包发出去;
-
应答流量:到达 NAT 设备后进行上述相反的转换,然后再转发给客户端。
几种常见的NAT类
- 完全锥形NAT:任何来自外部网络的数据包只要目标地址和端口号与映射匹配,就会被转发给内部网络中的相应设备。
- IP 限制锥形NAT:只有那些出向流量的 IP 地址传来的的数据包才会被转发给内部网络中的相应设备
- 端口限制锥形NAT:只有那些出向流量的 IP:port 地址传来的的数据包才会被转发给内部网络中的相应设备
- 对称形NAT:每次内部网络中的一个设备发起对外部网络的连接请求时,NAT设备都会为这个内部设备创建一个新的外部IP地址和端口的映射。
如何实现 NAT 穿透
如何在两台经过 NAT 的机器之间建立点对点连接?在上面的 NAT 原理中,我们知道了如何讲局域网设备接入公网。
此时产生了一个问题:虽然客户端通过 NAT 设备接入并能与公网设备进行连接,但是公网设备无法主动通过这个IP 和端口号找到客户端,只能被动的等客户端访问,才能通过该 IP:port 找到该设备,原因如下:
- 当局域网内的客户端主动发起连接到公网设备时,NAT路由器会记录这个连接的信息,包括客户端的私有IP和端口,以及发送的公网IP和端口,如(
123.123.123.123:123
)。 - 这个连接的映射会保留在NAT设备的连接表中,允许该公网地址返回的数据包顺利转发到局域网内的客户端。这并不是一个单纯的转发,因此公网设备无法主动的跟客户端建立连接。
这就是 NAT 设备的常见的「有状态/有朝向防火墙」,即允许所有出向连接,禁止所有入向连接。入向包只有在有相对应的出向包的情况下才会被允许进入(UDP协议)。
TURN 方案
防火墙朝向相同的穿透:
如上图所示,从内网访问公网上的某个服务器都属于这种情况。不论是客户端使用 vpn 与否,防火墙都是朝向相同的,因此只需客户端主动发起请求,所有相应的入向包就能进来。该穿透场景是客户端与公网服务器建立链接。通过一个 TURN 服务做中转的方案:
- 有公网上的一个这样 TURN 服务器并且双方分别在其中注册了某个标识(类似手机号)
- 某一方发起通信时,就可以直接通过请求 TURN 服务器,并由其将信息转发给对方实现。
STUN 方案
防火墙朝向不同的穿透:
但如果两个“客户端”想直连,这就意味着他们的防火墙是朝向相反的,除非让用户重新配置一边的防火墙,打开一个洞,不然就无法进行穿透,这个条件相当苛刻。
但是还有别的方案:要穿透这些有状态防火墙,我们只需要:让两端提前知道对方使用的 ip:port,这可以通过一个 STUN 服务实现:
-
客户端 => STUN 服务:我的 ip:port 是什么?
-
在业务逻辑获取对方的 IP:PORT 之后(信令服务器),只需要向该地址发送一个包(无论是否能被接受,目的就已经达到了),这样就将 防火墙 开了一个洞,后续该地址向客户端传的包都会被认为是应答包而被接收。
ICE 协议
这个框架的算法是寻找连接两个对等节点的最低延迟路径,结合了上述提到的方案,通常采用以下候选者的顺序寻找(不关注 TCP 连接的情况):
-
主机候选者:本地局域网内的 IP 地址及端口,优先级最高
-
反射候选者:获取 NAT 内主机的外网IP地址和端口,其实就是去尝试 STUN 尽可能多的进行穿透,并通过某种算法取优
-
中继候选者:通过服务器中转媒体数据
NAT 无法穿透的情况
如上述的对称形NAT,会根据每个不同的终点,为局域网内设备申请不同的端口,这也就意味着 STUN 服务器拿到的 ip:port 对其他 ip:port 的设备是无效的。(当然还有很多其他情况会导致无法穿透)
基于 websocket 的音视频通信 demo
webRtc 流程
本地 debugger : chrome://webrtc-internals/#185-19-table-T01
实现逻辑
服务端: 为每一个连接维护一个 websocket 实例,当一方客户端发起 offer/ice-candidate 事件时,向另一个 socket 实例发送数据,推送给另一个客户端。只需要做一些简单的转发逻辑,如:
// 交换 ice 候选者
socket.on('ice-candidate', (data) => {
console.log('candidate: ', data);
const { to, from, candidate } = data;
socket.to(to).emit('ice-candidate', { candidate, from, to });
});
客户端:实现通信,大致需要三步: 1. 本地设备校验,初始化 RTCPeerConnection 对象
navigator.mediaDevices.enumerateDevices()
.then(devices => {
devices.forEach(device => {
console.log('本地设备:', device);
});
// 提示用户给予使用媒体输入权限,产生一个 MediaStream 实例(https://developer.mozilla.org/zh-CN/docs/Web/API/MediaStream)
return navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
})
.then((stream) => {
localVideo.srcObject = stream;
localStream = stream;
targetSockId = toSomeone.value;
createRtc();
callButton.disabled = false;
})
// 创建连接
function createRtc() {
// 创建连接
localPeerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
]
});
}
2. 通话的发起者创建 Offer,通过 信令服务 向对方发送;接收者创建 answer ,通过信令服务发送给发起者。这个连接信息的交换过程,可能会让人联想到 TCP 的三次握手,为什么在这里建立连接只需要两次“握手”呢?
出现这个疑问,其实是对 webRtc 在应用中的位置有所不清。参考上面的画板,webRtc 技术主要是在浏览器的上一层,解决网络和跨平台的媒体设备IO问题,而交换 SDP 的过程,是在信令层(或者可以说是应用的业务逻辑)中实现的。交换 sdp 的过程是通过 ws 连接,而交换媒体流的过程是 webrtc 封装实现的,因此 这个交换过程本身并不像 tcp 握手一样需要保证连接可靠,而是单纯的信息交换。
简单理解:我们的信令逻辑,更类似于 http 的请求响应式逻辑。webRtc 的媒体交换,才更类似于 tcp 的建连过程。
function call() {
callButton.disabled = true;
hangupButton.disabled = false;
// 本地创建 offer(构建SDP),发起请求
localPeerConnection.createOffer(gotOffer, console.error);
}
function gotOffer(description) {
// 。。。
// 设置本地SDP - 触发 icecandidate 事件
localPeerConnection.setLocalDescription(description);
// 通过信令服务器发送 offer
socket.emit('offer', { offer: description, to: targetSockId, from: mySockId });
}
- 通过设置本地描述,拿到本地的 ICE 候选者信息,通过 信令服务 发送给对方,用以建立对等连接
function gotLocalIceCandidate(event) {
if (event.candidate) {
// 获取本地的 ICE 候选者,此时需要传递给对端
console.log('本地发送候选者:', event.candidate, 'to:', targetSockId, 'form:', mySockId);
socket.emit('ice-candidate', { candidate: event.candidate, to: targetSockId, from: mySockId });
}
}
SDP 格式
SDP 遵循 <type>=<value>
这样的格式,由于也是通过文本传输的,可以简单理解成类似 http 头的信息。正常情况一般不需要关注
# 1. 会话级别的描述(及其字段)
v= (protocol version)
o= (originator and session identifier)
s= (session name)
i= (session information)
u= (URI of description)
e= (email address)
p= (phone number)
c= (connection information -- not required if included in all media)
b= (zero or more bandwidth information lines)
# 2. 一个或多个时间描述
z= (time zone adjustments)
k= (encryption key)
a= (zero or more session attribute lines)
# 3. 零个或多个媒体级别的描述
# 时间描述的字段有这些
t= (time the session is active)
r= (zero or more repeat times)
# 媒体级别的描述字段有这些
m= (media name and transport address)
i= (media title)
c= (connection information -- optional if included at session level)
b= (zero or more bandwidth information lines)
k= (encryption key)
a= (zero or more media attribute lines) // 拓展字段
ICE 格式及优先级
{
"candidate": "candidate:1162430284 1 udp 2122260223 192.168.2.182 62071 typ host generation 0 ufrag nxXX network-id 1 network-cost 10",
"sdpMid": "0",
"sdpMLineIndex": 0,
"usernameFragment": "nxXX"
}
其中,candidate 的值由以下信息组成:
candidate:2183725366 1 udp 2122260223 192.168.2.182 64109 typ host generation 0 ufrag +EPG network-id 1 network-cost 10
candidate: 表示这是一个 ICE 候选者报文。
2183725366: 候选者的唯一标识符(基础值)。
1: 候选者的组件 ID,通常 1 表示 RTP,2 表示 RTCP。
udp: 候选者的传输协议,这里是 UDP。
2122260223: 候选者的优先级,(值越大越优先)。
192.168.2.182: 候选者的 IP 地址。
64109: 候选者的端口号。
typ host: 候选者的类型,host 表示这是本地主机的候选者。
generation 0: 候选者的生成次数,通常从 0 开始。
ufrag +EPG: 用户片段(User Fragment),用于身份验证。
network- id 1: 网络 ID,表示候选者所属的网络接口。
network-cost 10: 网络成本,表示候选者的网络质量。
常见的一些候选者的类型:
-
host 本地(局域网)UDP 连接,优先级 4
-
tcp host
:优先级 2 -
srflx
(Server Reflexive):通过 STUN 服务器得到的候选者,优先级 3- 在 srflx 字段后,会跟着 NAT 穿透的 IP 和 Port 值,如:
srflx
raddr
192.168.2.182
rport
64109
- 在 srflx 字段后,会跟着 NAT 穿透的 IP 和 Port 值,如:
-
relay
:通过TURN服务器转发数据,优先级 1relay raddr 124.160.65.50 rport 64109
我们有很多种方式选择我们更倾向使用的连接方式,如通过信令选择性发送ICE 候选者,或者修改 SDP 。
ICE 候选者更新与SDP 交换的顺序问题?
一般情况下,RTCPeerConnection 在创建offer或answer之前或之后,双方都可以开始交换 ICE 候选者信息(这个操作与交换 offer/answer 是并行的)。当通话的发起者收到 answer 之后开始 ICE 建连流程。(在交换候选者时 debug ,可以看到这种机制:在通话双方交换offer后,且在交换候选者并建连前,是无法实现通话的。)
也就是说,在交换 ICE 候选者环节实现之前,那怕通信双方已经交换了offer/answer ,p2p 链接也并没有建立。
这种机制,通过 PeerConnection 的 canTrickleIceCandidates
只读属性可以判断。他表示当前连接的对端设备是否支持涓流ICE(Trickle ICE),该属性在 setRemoteDescription 后可读。(本地是否支持,通过 sdp 可以看出,如a=ice-options:trickle
表示支持)
该协议允许逐步的发送/接收候选人,而不是完整交换整个列表。通过这种增量配置,ICE 代理就可以开始在仍在收集候选人的同时进行连接检查,显着缩短 ICE 处理所需的时间。
此外,如果一个设备不支持 Trickle ICE (即canTrickleIceCandidates
为 false),则需要在 ICE 交换流程结束后(iceGatheringState
属性为 "completed" 时),再交换 offer/answer,webRtc 会在 SDP 中包含完整的候选者列表以便一次性发送。伪代码如下:
// 接收到 offer 后处理
pc.setRemoteDescription(remoteOffer)
.then(_ => pc.createAnswer())
.then(answer => pc.setLocalDescription(answer))
.then(_ => {
if (pc.canTrickleIceCandidates) {
return pc.localDescription;
}
return new Promise(r => {
pc.addEventListener('icegatheringstatechange', e => {
if (e.target.iceGatheringState === 'complete') {
r(pc.localDescription);
}
});
});
})
.then(answer => sendAnswer(answer))
一些重要的 ICE 事件
交换 ICE 候选者的过程中,通常会包含多条 ICE candidates 信息,此时 WebRTC 会分别和这些 candidates 建立连接,然后选出其中最优的那条连接作为配对结果进行通话。此外,SDP 中可以通过 calculateNewPriority 函数手动配置 ICE candidates 的优先级
事件:icecandidate 事件最终会出现一个带有null
候选者的参数,表示没有更多的候选者可收集。
事件:iceConnectionState,描述候选者连接状态。包含:new
, checking
, connected
, completed
, failed
, disconnected
, closed
。
connected
状态表示任意一个候选者已经成功建立了连接。即使后续发现了更合适的候选者并更换了连接,这个状态也不会发生改变。延迟调用 addIceCandidate ,不会影响他的状态,但是会更新连接
事件:iceGatheringState,描述候选者(本地)收集状态,包含枚举: 'new' 'gathering' 'complete'。
表示的是该 pc 连接的整体收集状态;而 RTCIceTransport.gatheringState 表示单个候选连接的状态。
值得注意的是,在 pc 的 iceGatheringState 变为 complete 时,如果通过添加 ice 服务的方式增加候选者(代码),他会继续变为 gathering (收集状态)。
延迟调用 addIceCandidate ,也不会影响他的状态,因为他表示的是本地端的 ICE 候选者,addIceCandidate 添加的是远程的候选者。
参考文章:
developer.mozilla.org/zh-CN/docs/…
完整 demo 代码
// index.js
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
// 创建 Express 应用
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
// 提供静态文件服务(例如 HTML 文件)
app.use(express.static('public'));
// 监听 HTTP 请求并发送主页面
app.get('/', (req, res) => {
res.sendFile(__dirname + '/public/index.html');
});
app.get('/public', (req, res) => {
res.sendFile(__dirname + '/public');
});
io.on('connection', (socket) => {
socket.on('offer', (data) => {
const { to, from, offer } = data;
socket.to(to).emit('offer', { offer, from, to });
});
socket.on('answer', (data) => {
console.log('answer: ', data);
const { to, from, answer } = data;
socket.to(to).emit('answer', { answer, from, to });
});
socket.on('ice-candidate', (data) => {
console.log('candidate: ', data);
const { to, from, candidate } = data;
socket.to(to).emit('ice-candidate', { candidate, from, to });
});
socket.on('disconnect', (data) => {
socket.to(data.to).emit('disconnected', data);
});
});
server.listen(1010, () => {
console.log('Server is running on port 1010');
});
// public/index.js
let localStream, remoteStream, localPeerConnection;
const localVideo = document.getElementById("localVideo");
const remoteVideo = document.getElementById("remoteVideo");
const startButton = document.getElementById("startButton");
const callButton = document.getElementById("callButton");
const hangupButton = document.getElementById("hangupButton");
const toSomeone = document.getElementById("to");
const Me = document.getElementById("from");
const addIceBtn = document.getElementById("addIceBtn");
const reCreateOfferBtn = document.getElementById("reCreateOfferBtn");
startButton.disabled = false;
callButton.disabled = true;
hangupButton.disabled = true;
startButton.onclick = start;
callButton.onclick = call;
hangupButton.onclick = () => hangup(true);
addIceBtn.onclick = () => updateIceServers([{ urls: 'stun:stun.l.google.com:19302' }]);
reCreateOfferBtn.onclick = reCreateOffer;
const socket = io();
let socketServerAlive = false;
let mySockId = null;
let targetSockId = null;
socket.on("connect", () => {
socketServerAlive = true;
Me.innerText = socket.id;
mySockId = socket.id;
});
socket.on("disconnect", () => {
socketServerAlive = false;
Me.value = undefined;
mySockId = null;
});
socket.on('ice-candidate', (data) => {
const { to, from, candidate } = data;
console.log('远端传来候选者: ', candidate);
if (mySockId === to && candidate !== null) {
// debugger
localPeerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
});
socket.on('offer', (data) => {
// console.log('远端传来offer: ', data);
const { to, from, offer: description } = data;
if (mySockId === to) {
// 获取到远程媒体流后,就可以在本地进行操作
localPeerConnection.onaddstream = gotRemoteStream;
localPeerConnection && localPeerConnection.setRemoteDescription(description);
localPeerConnection.createAnswer(gotAnswer, console.error);
socket.emit('answer', { answer: localPeerConnection.localDescription, to, from });
callButton.disabled = true;
hangupButton.disabled = false;
}
});
socket.on('answer', (data) => {
// console.log('远端传来answer: ', data);
const { to, from, answer: description } = data;
if (mySockId === to) {
// 获取到远程媒体流后,就可以在本地进行操作
localPeerConnection.onaddstream = gotRemoteStream;
// setRemoteDescription() 更改远程描述时,会抛出 addstream 事件
localPeerConnection && localPeerConnection.setRemoteDescription(description);
}
});
socket.on('disconnect', (data) => {
// console.log('远端传来断开连接: ');
const { to, from } = data;
if (mySockId === to) {
hangup();
}
});
function start() {
if (!socketServerAlive) {
return console.error('信令服务器未连接');
}
if (!toSomeone.value) {
return console.log('请选择要打电话的目标');
}
startButton.disabled = true;
// enumerateDevices 请求一个可用的媒体输入和输出设备的列表
navigator.mediaDevices.enumerateDevices()
.then(devices => {
devices.forEach(device => {
// console.log('本地设备:', device);
});
// 提示用户给予使用媒体输入权限,产生一个 MediaStream 实例(https://developer.mozilla.org/zh-CN/docs/Web/API/MediaStream)
return navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
})
.then((stream) => {
localVideo.srcObject = stream;
localStream = stream;
targetSockId = toSomeone.value;
createRtc();
callButton.disabled = false;
})
.catch(error => {
console.error('媒体设备访问失败', error);
});
}
function createRtc() {
// 创建连接
localPeerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
]
});
// 添加本地媒体流,用于向对端传输
localPeerConnection.addStream(localStream);
localPeerConnection.addEventListener('icegatheringstatechange', e => {
console.log('ice gathering 状态改变: ', e.target.iceGatheringState);
});
localPeerConnection.addEventListener('connectionstatechange', e => {
console.log('connection 状态改变: ', e.target.connectionState);
});
}
function call() {
callButton.disabled = true;
hangupButton.disabled = false;
if (localStream.getVideoTracks().length > 0) {
// console.log('视频设备: ' + localStream.getVideoTracks()[0].label);
}
if (localStream.getAudioTracks().length > 0) {
// console.log('音频设备: ' + localStream.getAudioTracks()[0].label);
}
// 本地创建 offer(构建SDP),发起请求
localPeerConnection.createOffer(gotOffer, console.error);
}
function gotLocalIceCandidate(event) {
// if(!event.candidate || event.candidate.candidate.includes('host')) return;
// 比较奇怪的是,延迟发送更优质的 ice 候选者,并不会触发 iceGatheringState。
// if(!event.candidate || event.candidate.candidate.includes('host')) {
// setTimeout(() => {
// console.log('延迟 7s 后发送候选者:', event.candidate, 'to:', targetSockId, 'form:', mySockId);
// socket.emit('ice-candidate', { candidate: event.candidate, to: targetSockId, from: mySockId });
// }, 7000);
// } else {
// 获取本地的 ICE 候选者,此时需要传递给对端
console.log('本地发送候选者:', event.candidate, 'to:', targetSockId, 'form:', mySockId);
socket.emit('ice-candidate', { candidate: event.candidate, to: targetSockId, from: mySockId });
// }
}
function gotRemoteStream(event) {
// 渲染对端媒体流
remoteVideo.srcObject = event.stream;
}
function gotOffer(description) {
// 需要将描述信息传给远端 RTCPeerConnection,以更新远端的备选源
localPeerConnection.onicecandidate = gotLocalIceCandidate;
// 设置本地SDP - 触发 icecandidate 事件
localPeerConnection.setLocalDescription(description);
// 通过信令服务器发送 offer
console.log('发送 offer,本地SDP:', description);
socket.emit('offer', { offer: description, to: targetSockId, from: mySockId });
}
function gotAnswer(description) {
// 需要将描述信息传给远端 RTCPeerConnection,以更新远端的备选源
localPeerConnection.onicecandidate = gotLocalIceCandidate;
// 设置本地SDP - 触发 icecandidate 事件
localPeerConnection.setLocalDescription(description);
// 通过信令服务器发送 answer
console.log('发送 answer,本地SDP:', description);
socket.emit('answer', { answer: description, to: targetSockId, from: mySockId });
}
function hangup(emitDisconnect) {
localPeerConnection.close();
if (emitDisconnect) socket.emit('disconnect', { to: targetSockId, from: mySockId })
localPeerConnection = null;
hangupButton.disabled = true;
callButton.disabled = false;
}
// 动态更新 ICE 服务器配置
function updateIceServers(newIceServers) {
console.log('更新 ICE 服务器配置: ', newIceServers)
localPeerConnection.setConfiguration({ iceServers: newIceServers });
}
// 重新创建 offer
function reCreateOffer(newIceServers) {
localPeerConnection.createOffer().then(offer => {
return localPeerConnection.setLocalDescription(offer);
}).then(() => {
console.log('重新创建 offer');
})
}
// public/index.html
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>PeerConnection() example</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js" integrity="sha384-2huaZvOR9iDzHqslqwpR87isEmrfxqyWOF7hr7BY6KG0+hVKLoEXMPUJw3ynWuhO" crossorigin="anonymous"></script>
</head>
<body>
我是:<span id="from"></span>,
给:<input id="to">打电话
<table border="1" width="100%">
<tr>
<th>Local video</th>
<th>'Remote' video</th>
</tr>
<tr>
<td><video id="localVideo" autoplay></video></td>
<td><video id="remoteVideo" autoplay></video></td>
</tr>
<tr>
<td align="center">
<div>
<button id="startButton">Start</button>
<button id="callButton">Call</button>
<button id="hangupButton">Hang Up</button>
<button id="addIceBtn">addIceServer</button>
<button id="reCreateOfferBtn">reCreateOfferBtn</button>
</div>
</td>
<td><!-- void --></td>
</tr>
</table>
<script src="./index.js"></script>
</body>
</html>